langchain_security_extension.security_adapter_langchain
LangChain & LangGraph Adapter package exports.
Re-exports integration components including @secured_tool, SecureRunnable,
SecureRetriever, SecureCallbackHandler, DocumentSecurityTransformer, and LangGraphNodeGuard.
1""" 2LangChain & LangGraph Adapter package exports. 3 4Re-exports integration components including `@secured_tool`, `SecureRunnable`, 5`SecureRetriever`, `SecureCallbackHandler`, `DocumentSecurityTransformer`, and `LangGraphNodeGuard`. 6""" 7 8from langchain_security_extension.security_adapter_langchain.callback_handler import SecureCallbackHandler 9from langchain_security_extension.security_adapter_langchain.tool_decorator import secured_tool, SecuredToolConfig, get_tool_schema 10from langchain_security_extension.security_adapter_langchain.retriever_wrapper import SecureRetriever, SecureDocument 11from langchain_security_extension.security_adapter_langchain.ingestion import ( 12 DocumentSecurityTransformer, 13 SecureDocumentChunkEnricher, 14 SecureEnvelopeValidator, 15 SecurityEnvelopeValidator, 16 SecureVectorStoreIngestor, 17 SecureTextSplitter, 18 ChunkSecurityEnricherSPI, 19 ChunkEnrichmentContext, 20 HeadingClassificationEnricher, 21 DlpSecretDetectionEnricher, 22) 23from langchain_security_extension.security_adapter_langchain.runnable_guard import SecureRunnable 24from langchain_security_extension.security_adapter_langchain.langgraph_integration import ( 25 SecuredAgentState, 26 LangGraphNodeGuard, 27) 28from langchain_security_extension.security_adapter_langchain.violation_handler import ( 29 ToolViolationHandler, 30 RetryBudgetTracker, 31 AbuseThrottleTracker, 32 ViolationResolution, 33) 34 35__all__ = [ 36 "SecureCallbackHandler", 37 "secured_tool", 38 "SecuredToolConfig", 39 "get_tool_schema", 40 "SecureRetriever", 41 "SecureDocument", 42 "DocumentSecurityTransformer", 43 "SecureDocumentChunkEnricher", 44 "SecureEnvelopeValidator", 45 "SecurityEnvelopeValidator", 46 "SecureVectorStoreIngestor", 47 "SecureTextSplitter", 48 "ChunkSecurityEnricherSPI", 49 "ChunkEnrichmentContext", 50 "HeadingClassificationEnricher", 51 "DlpSecretDetectionEnricher", 52 "SecureRunnable", 53 "SecuredAgentState", 54 "LangGraphNodeGuard", 55 "ToolViolationHandler", 56 "RetryBudgetTracker", 57 "AbuseThrottleTracker", 58 "ViolationResolution", 59]
21class SecureCallbackHandler(BaseCallbackHandler): 22 """LangChain callback handler performing pre-execution PEP evaluations. 23 24 Attributes: 25 pdp_engine (PolicyDecisionEngine): Configured PDP decision engine instance. 26 security_identity (SecurityIdentity): Active caller security identity envelope. 27 """ 28 29 def __init__( 30 self, 31 pdp_engine: PolicyDecisionEngine, 32 security_identity: SecurityIdentity, 33 ): 34 """Initialize SecureCallbackHandler. 35 36 Args: 37 pdp_engine: Policy decision engine instance. 38 security_identity: Caller identity envelope. 39 """ 40 self._pdp_engine = pdp_engine 41 self._security_identity = security_identity 42 43 @classmethod 44 def create( 45 cls, 46 pdp_engine: PolicyDecisionEngine, 47 security_identity: SecurityIdentity, 48 ): 49 """Factory method to construct SecureCallbackHandler. 50 51 Args: 52 pdp_engine: Policy decision engine. 53 security_identity: Security identity object. 54 55 Returns: 56 SecureCallbackHandler: Constructed handler instance. 57 """ 58 return cls(pdp_engine=pdp_engine, security_identity=security_identity) 59 60 @property 61 def pdp_engine(self) -> PolicyDecisionEngine: 62 """Get the configured PolicyDecisionEngine.""" 63 return self._pdp_engine 64 65 @property 66 def security_identity(self) -> SecurityIdentity: 67 """Get the bound SecurityIdentity.""" 68 return self._security_identity 69 70 def on_chain_start( 71 self, 72 serialized: Dict[str, Any], 73 inputs: Dict[str, Any], 74 run_id: Any, 75 parent_run_id: Any, 76 tags: Optional[List[str]], 77 metadata: Optional[Dict[str, Any]], 78 **kwargs: Any, 79 ) -> None: 80 """Agent Invocation Guard intercepting chain entry and evaluating access permissions. 81 82 Args: 83 serialized: Serialized chain metadata mapping. 84 inputs: Chain input arguments mapping. 85 run_id: Execution run ID. 86 parent_run_id: Parent run ID if nested. 87 tags: Operational tags list. 88 metadata: Additional execution metadata. 89 90 Raises: 91 SecurityAuthorizationError: If PDP denies chain entry. 92 """ 93 chain_name = str(serialized.get("name") or serialized.get("id") or "LangChainRunnable") 94 req_roles = serialized.get("required_roles") or (metadata.get("required_roles") if metadata else None) 95 min_clr = serialized.get("min_clearance") or (metadata.get("min_clearance") if metadata else None) 96 tenant = (metadata.get("tenant_id") if metadata else None) or serialized.get("tenant_id") or self._security_identity.tenant_id 97 98 request = PolicyEvaluationRequest.create( 99 subject=self._security_identity, 100 action=ACTION_AGENT_INVOKE, 101 resource={ 102 "target_id": chain_name, 103 "tenant_id": tenant, 104 "required_roles": req_roles, 105 "min_clearance": min_clr, 106 }, 107 context={"run_id": str(run_id), "metadata": metadata or {}}, 108 ) 109 decision = self._pdp_engine.evaluate(request) 110 if not decision.is_permitted: 111 raise SecurityAuthorizationError.create( 112 message=f"Agent invocation denied for '{chain_name}': {decision.diagnostic_reason}", 113 diagnostic_reason=decision.diagnostic_reason, 114 obligations=decision.obligations, 115 ) 116 117 def on_tool_start( 118 self, 119 serialized: Dict[str, Any], 120 input_str: str, 121 run_id: Any, 122 parent_run_id: Any, 123 tags: Optional[List[str]], 124 metadata: Optional[Dict[str, Any]], 125 inputs: Optional[Dict[str, Any]], 126 **kwargs: Any, 127 ) -> None: 128 """Tool Execution Interceptor hook evaluating tool access and CEL parameter constraints. 129 130 Args: 131 serialized: Serialized tool metadata map. 132 input_str: Raw tool input string. 133 run_id: Run ID. 134 parent_run_id: Parent run ID. 135 tags: Event tags. 136 metadata: Metadata dictionary. 137 inputs: Structured tool inputs map. 138 139 Raises: 140 ToolExecutionDeniedError: If PDP denies tool execution. 141 """ 142 tool_name = str(serialized.get("name") or "LangChainTool") 143 tool_args = inputs if inputs is not None else {"raw_input": input_str} 144 req_roles = serialized.get("required_roles") or (metadata.get("required_roles") if metadata else None) 145 min_clr = serialized.get("min_clearance") or (metadata.get("min_clearance") if metadata else None) 146 cel_expr = serialized.get("cel_expression") or (metadata.get("cel_expression") if metadata else None) 147 is_mutative = bool(serialized.get("is_mutative") or (metadata.get("is_mutative") if metadata else True)) 148 tenant = (metadata.get("tenant_id") if metadata else None) or serialized.get("tenant_id") or self._security_identity.tenant_id 149 150 request = PolicyEvaluationRequest.create( 151 subject=self._security_identity, 152 action=ACTION_TOOL_EXECUTE, 153 resource={ 154 "name": tool_name, 155 "target_id": tool_name, 156 "tenant_id": tenant, 157 "required_roles": req_roles, 158 "min_clearance": min_clr, 159 "cel_expression": cel_expr, 160 "is_mutative": is_mutative, 161 "arguments": tool_args, 162 "tool_metadata": { 163 "name": tool_name, 164 "isMutative": is_mutative, 165 }, 166 }, 167 context={"run_id": str(run_id), "metadata": metadata or {}}, 168 ) 169 decision = self._pdp_engine.evaluate(request) 170 if not decision.is_permitted: 171 strategy = decision.obligations.get("strategy") 172 if strategy == "RETURN_FEEDBACK" and not is_mutative: 173 refusal_payload = { 174 "status": "ACCESS_DENIED", 175 "tool": tool_name, 176 "reason": f"Tool execution denied for '{tool_name}': {decision.diagnostic_reason}", 177 "diagnostic_reason": decision.diagnostic_reason, 178 } 179 raise ToolExecutionFeedbackException.create_payload(tool_name, refusal_payload) 180 raise ToolExecutionDeniedError.for_tool( 181 tool_name=tool_name, 182 message=f"Tool execution denied for '{tool_name}': {decision.diagnostic_reason}", 183 diagnostic_reason=decision.diagnostic_reason, 184 )
LangChain callback handler performing pre-execution PEP evaluations.
Attributes: pdp_engine (PolicyDecisionEngine): Configured PDP decision engine instance. security_identity (SecurityIdentity): Active caller security identity envelope.
29 def __init__( 30 self, 31 pdp_engine: PolicyDecisionEngine, 32 security_identity: SecurityIdentity, 33 ): 34 """Initialize SecureCallbackHandler. 35 36 Args: 37 pdp_engine: Policy decision engine instance. 38 security_identity: Caller identity envelope. 39 """ 40 self._pdp_engine = pdp_engine 41 self._security_identity = security_identity
Initialize SecureCallbackHandler.
Args: pdp_engine: Policy decision engine instance. security_identity: Caller identity envelope.
43 @classmethod 44 def create( 45 cls, 46 pdp_engine: PolicyDecisionEngine, 47 security_identity: SecurityIdentity, 48 ): 49 """Factory method to construct SecureCallbackHandler. 50 51 Args: 52 pdp_engine: Policy decision engine. 53 security_identity: Security identity object. 54 55 Returns: 56 SecureCallbackHandler: Constructed handler instance. 57 """ 58 return cls(pdp_engine=pdp_engine, security_identity=security_identity)
Factory method to construct SecureCallbackHandler.
Args: pdp_engine: Policy decision engine. security_identity: Security identity object.
Returns: SecureCallbackHandler: Constructed handler instance.
60 @property 61 def pdp_engine(self) -> PolicyDecisionEngine: 62 """Get the configured PolicyDecisionEngine.""" 63 return self._pdp_engine
Get the configured PolicyDecisionEngine.
65 @property 66 def security_identity(self) -> SecurityIdentity: 67 """Get the bound SecurityIdentity.""" 68 return self._security_identity
Get the bound SecurityIdentity.
70 def on_chain_start( 71 self, 72 serialized: Dict[str, Any], 73 inputs: Dict[str, Any], 74 run_id: Any, 75 parent_run_id: Any, 76 tags: Optional[List[str]], 77 metadata: Optional[Dict[str, Any]], 78 **kwargs: Any, 79 ) -> None: 80 """Agent Invocation Guard intercepting chain entry and evaluating access permissions. 81 82 Args: 83 serialized: Serialized chain metadata mapping. 84 inputs: Chain input arguments mapping. 85 run_id: Execution run ID. 86 parent_run_id: Parent run ID if nested. 87 tags: Operational tags list. 88 metadata: Additional execution metadata. 89 90 Raises: 91 SecurityAuthorizationError: If PDP denies chain entry. 92 """ 93 chain_name = str(serialized.get("name") or serialized.get("id") or "LangChainRunnable") 94 req_roles = serialized.get("required_roles") or (metadata.get("required_roles") if metadata else None) 95 min_clr = serialized.get("min_clearance") or (metadata.get("min_clearance") if metadata else None) 96 tenant = (metadata.get("tenant_id") if metadata else None) or serialized.get("tenant_id") or self._security_identity.tenant_id 97 98 request = PolicyEvaluationRequest.create( 99 subject=self._security_identity, 100 action=ACTION_AGENT_INVOKE, 101 resource={ 102 "target_id": chain_name, 103 "tenant_id": tenant, 104 "required_roles": req_roles, 105 "min_clearance": min_clr, 106 }, 107 context={"run_id": str(run_id), "metadata": metadata or {}}, 108 ) 109 decision = self._pdp_engine.evaluate(request) 110 if not decision.is_permitted: 111 raise SecurityAuthorizationError.create( 112 message=f"Agent invocation denied for '{chain_name}': {decision.diagnostic_reason}", 113 diagnostic_reason=decision.diagnostic_reason, 114 obligations=decision.obligations, 115 )
Agent Invocation Guard intercepting chain entry and evaluating access permissions.
Args: serialized: Serialized chain metadata mapping. inputs: Chain input arguments mapping. run_id: Execution run ID. parent_run_id: Parent run ID if nested. tags: Operational tags list. metadata: Additional execution metadata.
Raises: SecurityAuthorizationError: If PDP denies chain entry.
117 def on_tool_start( 118 self, 119 serialized: Dict[str, Any], 120 input_str: str, 121 run_id: Any, 122 parent_run_id: Any, 123 tags: Optional[List[str]], 124 metadata: Optional[Dict[str, Any]], 125 inputs: Optional[Dict[str, Any]], 126 **kwargs: Any, 127 ) -> None: 128 """Tool Execution Interceptor hook evaluating tool access and CEL parameter constraints. 129 130 Args: 131 serialized: Serialized tool metadata map. 132 input_str: Raw tool input string. 133 run_id: Run ID. 134 parent_run_id: Parent run ID. 135 tags: Event tags. 136 metadata: Metadata dictionary. 137 inputs: Structured tool inputs map. 138 139 Raises: 140 ToolExecutionDeniedError: If PDP denies tool execution. 141 """ 142 tool_name = str(serialized.get("name") or "LangChainTool") 143 tool_args = inputs if inputs is not None else {"raw_input": input_str} 144 req_roles = serialized.get("required_roles") or (metadata.get("required_roles") if metadata else None) 145 min_clr = serialized.get("min_clearance") or (metadata.get("min_clearance") if metadata else None) 146 cel_expr = serialized.get("cel_expression") or (metadata.get("cel_expression") if metadata else None) 147 is_mutative = bool(serialized.get("is_mutative") or (metadata.get("is_mutative") if metadata else True)) 148 tenant = (metadata.get("tenant_id") if metadata else None) or serialized.get("tenant_id") or self._security_identity.tenant_id 149 150 request = PolicyEvaluationRequest.create( 151 subject=self._security_identity, 152 action=ACTION_TOOL_EXECUTE, 153 resource={ 154 "name": tool_name, 155 "target_id": tool_name, 156 "tenant_id": tenant, 157 "required_roles": req_roles, 158 "min_clearance": min_clr, 159 "cel_expression": cel_expr, 160 "is_mutative": is_mutative, 161 "arguments": tool_args, 162 "tool_metadata": { 163 "name": tool_name, 164 "isMutative": is_mutative, 165 }, 166 }, 167 context={"run_id": str(run_id), "metadata": metadata or {}}, 168 ) 169 decision = self._pdp_engine.evaluate(request) 170 if not decision.is_permitted: 171 strategy = decision.obligations.get("strategy") 172 if strategy == "RETURN_FEEDBACK" and not is_mutative: 173 refusal_payload = { 174 "status": "ACCESS_DENIED", 175 "tool": tool_name, 176 "reason": f"Tool execution denied for '{tool_name}': {decision.diagnostic_reason}", 177 "diagnostic_reason": decision.diagnostic_reason, 178 } 179 raise ToolExecutionFeedbackException.create_payload(tool_name, refusal_payload) 180 raise ToolExecutionDeniedError.for_tool( 181 tool_name=tool_name, 182 message=f"Tool execution denied for '{tool_name}': {decision.diagnostic_reason}", 183 diagnostic_reason=decision.diagnostic_reason, 184 )
Tool Execution Interceptor hook evaluating tool access and CEL parameter constraints.
Args: serialized: Serialized tool metadata map. input_str: Raw tool input string. run_id: Run ID. parent_run_id: Parent run ID. tags: Event tags. metadata: Metadata dictionary. inputs: Structured tool inputs map.
Raises: ToolExecutionDeniedError: If PDP denies tool execution.
187def secured_tool( 188 required_roles: List[str], 189 min_clearance: int, 190 cel_expression: str, 191 is_mutative: bool, 192 *args: Any, 193 **kwargs: Any, 194) -> Callable: 195 """Decorator securing tool functions with pre-execution authorization check. 196 197 Args: 198 required_roles: List of mandatory role strings for execution. 199 min_clearance: Clearance floor rank required. 200 cel_expression: CEL condition evaluating caller identity and arguments. 201 is_mutative: True if tool modifies system state, triggering HARD_ABORT on failure. 202 *args: Optional positional arguments (pdp_engine, entity_resolver, masked_params). 203 **kwargs: Optional keyword arguments (pdp_engine, entity_resolver, masked_params). 204 205 Returns: 206 Callable: Wrapped target tool function with PEP interceptor guard. 207 """ 208 pdp_engine: Optional[PolicyDecisionEngine] = kwargs.get("pdp_engine", args[0] if len(args) > 0 else None) 209 entity_resolver: Optional[Any] = kwargs.get("entity_resolver", args[1] if len(args) > 1 else None) 210 masked_params: List[str] = list(kwargs.get("masked_params", args[2] if len(args) > 2 else [])) 211 212 def decorator(func: Callable) -> Callable: 213 @functools.wraps(func) 214 def wrapper(*args: Any, **kwargs: Any) -> Any: 215 caller_config = kwargs.get("config") or kwargs.get("runnable_config") 216 caller_identity = kwargs.pop("security_identity", None) 217 218 if caller_identity is None and caller_config: 219 caller_identity = IdentityBridge.extract_from_config(caller_config) 220 221 if caller_identity is None and args and isinstance(args[0], SecurityIdentity): 222 caller_identity = args[0] 223 args = args[1:] 224 225 caller_identity = IdentityBridge.resolve_identity(caller_identity) 226 227 if caller_config is None and caller_identity is not None: 228 caller_config = IdentityBridge.create_runnable_config(caller_identity) 229 230 # Resolve PDP engine if None 231 engine = pdp_engine 232 if engine is None and "config" in kwargs and isinstance(kwargs["config"], dict): 233 cfg_conf = kwargs["config"].get("configurable", {}) 234 if isinstance(cfg_conf, dict): 235 engine = cfg_conf.get("sec:pdp_engine") or cfg_conf.get("pdp_engine") 236 237 if engine is None and args and hasattr(args[0], "pdp_engine") and getattr(args[0], "pdp_engine") is not None: 238 engine = getattr(args[0], "pdp_engine") 239 240 if engine is None and "config" in kwargs and isinstance(kwargs["config"], dict): 241 cfg_conf = kwargs["config"].get("configurable", {}) 242 if isinstance(cfg_conf, dict): 243 vh = cfg_conf.get("sec:violation_handler") 244 if vh and hasattr(vh, "pdp_engine"): 245 engine = vh.pdp_engine 246 if engine is None: 247 callbacks_list = kwargs["config"].get("callbacks", []) 248 if isinstance(callbacks_list, list): 249 for cb in callbacks_list: 250 if hasattr(cb, "pdp_engine"): 251 engine = cb.pdp_engine 252 break 253 254 if engine is None: 255 from langchain_security_extension.security_core.registry import SecurityRegistry 256 engine = SecurityRegistry.get_pdp_engine() 257 258 if engine is None: 259 from langchain_security_extension.security_core.pdp import EmbeddedPolicyEngine 260 engine = EmbeddedPolicyEngine.create() 261 262 resolved_resolver = entity_resolver 263 if resolved_resolver is None and args and hasattr(args[0], "entity_resolver") and getattr(args[0], "entity_resolver") is not None: 264 resolved_resolver = getattr(args[0], "entity_resolver") 265 266 if resolved_resolver is None and "config" in kwargs and isinstance(kwargs["config"], dict): 267 cfg_conf = kwargs["config"].get("configurable", {}) 268 if isinstance(cfg_conf, dict): 269 resolved_resolver = cfg_conf.get("sec:entity_resolver") or cfg_conf.get("entity_resolver") 270 271 if resolved_resolver is None and engine is not None and hasattr(engine, "_cel_evaluator") and engine._cel_evaluator: 272 resolved_resolver = getattr(engine._cel_evaluator, "entity_resolver", None) 273 274 if resolved_resolver is None: 275 from langchain_security_extension.security_core.registry import SecurityRegistry 276 resolved_resolver = SecurityRegistry.get_entity_resolver() 277 278 if resolved_resolver and hasattr(engine, "_cel_evaluator") and engine._cel_evaluator: 279 engine._cel_evaluator.entity_resolver = resolved_resolver 280 281 import inspect 282 283 # Bind positional and keyword arguments to parameter names 284 bound_args: Dict[str, Any] = {} 285 try: 286 sig = inspect.signature(func) 287 params = list(sig.parameters.keys()) 288 if params and params[0] in ("self", "cls"): 289 if not args or not (hasattr(args[0], "__class__") and args[0].__class__.__name__ in func.__qualname__): 290 args_to_bind = (None,) + args 291 else: 292 args_to_bind = args 293 else: 294 args_to_bind = args 295 296 clean_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters} 297 bound = sig.bind_partial(*args_to_bind, **clean_kwargs) 298 bound.apply_defaults() 299 bound_args = dict(bound.arguments) 300 except Exception: 301 bound_args = dict(kwargs) 302 303 bound_args.pop("config", None) 304 bound_args.pop("self", None) 305 bound_args.pop("cls", None) 306 307 aliases: Dict[str, Any] = {} 308 for k, v in list(bound_args.items()): 309 if "_" in k: 310 parts = k.split("_") 311 camel = parts[0] + "".join(p.capitalize() for p in parts[1:]) 312 aliases[camel] = v 313 else: 314 import re 315 snake = re.sub(r'(?<!^)(?=[A-Z])', '_', k).lower() 316 aliases[snake] = v 317 bound_args.update(aliases) 318 319 # Resolve target tenant: check decorator kwargs, bound arguments (e.g. tenant_id/tenantId), or default to caller 320 resolved_tenant = kwargs.get("target_tenant") or bound_args.get("tenant_id") or bound_args.get("tenantId") or caller_identity.tenant_id 321 322 tool_name = func.__name__ 323 tool_module = getattr(func, "__module__", "") 324 325 resource_descriptor = { 326 "name": tool_name, 327 "target_id": tool_name, 328 "tenant_id": resolved_tenant, 329 "required_roles": required_roles, 330 "min_clearance": min_clearance, 331 "cel_expression": cel_expression, 332 "is_mutative": is_mutative, 333 "arguments": bound_args, 334 "masked_params": masked_params, 335 "tool_metadata": { 336 "name": tool_name, 337 "isMutative": is_mutative, 338 "module": tool_module, 339 "classificationLevel": min_clearance, 340 }, 341 } 342 343 request = PolicyEvaluationRequest.create( 344 subject=caller_identity, 345 action=ACTION_TOOL_EXECUTE, 346 resource=resource_descriptor, 347 context={"config": kwargs.get("config")}, 348 ) 349 350 decision = engine.evaluate(request) 351 352 if not decision.is_permitted: 353 # Retry budget tracking & parameter escalation checking in config 354 cfg = kwargs.get("config") 355 retries = 0 356 prev_args = None 357 param_escalation_detected = False 358 359 if isinstance(cfg, dict) and isinstance(cfg.get("configurable"), dict): 360 retries = cfg["configurable"].get("sec:retry_count", 0) + 1 361 cfg["configurable"]["sec:retry_count"] = retries 362 prev_args = cfg["configurable"].get("sec:prev_args") 363 cfg["configurable"]["sec:prev_args"] = bound_args 364 365 if prev_args is not None and prev_args != bound_args: 366 param_escalation_detected = True 367 368 strategy = decision.obligations.get("strategy", STRATEGY_HARD_ABORT if is_mutative else STRATEGY_RETURN_FEEDBACK) 369 reason_msg = f"Policy violation: Access denied for tool '{tool_name}' ({decision.diagnostic_reason})" 370 371 if param_escalation_detected and hasattr(engine, "audit_manager") and engine.audit_manager: 372 from langchain_security_extension.security_core.audit import AuditEvent 373 from langchain_security_extension.security_core.constants import ( 374 EVENT_PARAMETER_PRIVILEGE_ESCALATION, 375 PEP_TOOL_INTERCEPTOR, 376 SEVERITY_SECURITY_ALERT, 377 ) 378 engine.audit_manager.record_event(AuditEvent.create( 379 subject_id=caller_identity.subject_id, 380 tenant_id=caller_identity.tenant_id, 381 action=ACTION_TOOL_EXECUTE, 382 resource=tool_name, 383 decision="DENIED", 384 diagnostic_reason="Parameter privilege escalation attempt detected: arguments mutated across retries", 385 severity=SEVERITY_SECURITY_ALERT, 386 metadata={"previous_args": prev_args, "current_args": bound_args}, 387 enforcement_point=PEP_TOOL_INTERCEPTOR, 388 reason_code=EVENT_PARAMETER_PRIVILEGE_ESCALATION, 389 )) 390 391 # Record violation with registered ToolViolationHandler if present in config 392 vh = None 393 if isinstance(cfg, dict) and isinstance(cfg.get("configurable"), dict): 394 vh = cfg["configurable"].get("sec:violation_handler") 395 396 resolution = None 397 if vh is not None and hasattr(vh, "record_tool_violation"): 398 resolution = vh.record_tool_violation( 399 tool_name=tool_name, 400 diagnostic_reason=str(decision.diagnostic_reason or ""), 401 is_mutative=is_mutative, 402 config=cfg, 403 caller_identity=caller_identity, 404 ) 405 elif isinstance(cfg, dict) and isinstance(cfg.get("configurable"), dict): 406 from langchain_security_extension.security_adapter_langchain.violation_handler import ( 407 ViolationResolution, 408 STATUS_BLOCKED_BY_POLICY, 409 POLICY_STATUS_BLOCKED, 410 POLICY_STATUS_FEEDBACK, 411 EXECUTION_ACTION_ABORTED, 412 EXECUTION_ACTION_FEEDBACK, 413 ) 414 should_abort_flag = bool(strategy == STRATEGY_HARD_ABORT or is_mutative or retries > 2) 415 resolution = ViolationResolution.create( 416 status=STATUS_BLOCKED_BY_POLICY if should_abort_flag else "ACCESS_DENIED", 417 action_entry=f"Blocked execution of tool '{tool_name}' ({decision.diagnostic_reason})", 418 root_cause=f"Policy violation: Access denied for tool '{tool_name}' ({decision.diagnostic_reason})", 419 user_message=reason_msg, 420 policy_decision=POLICY_STATUS_BLOCKED if should_abort_flag else POLICY_STATUS_FEEDBACK, 421 execution_action=EXECUTION_ACTION_ABORTED if should_abort_flag else EXECUTION_ACTION_FEEDBACK, 422 should_abort=should_abort_flag, 423 diagnostic_reason=decision.diagnostic_reason, 424 tool_name=tool_name, 425 ) 426 cfg["configurable"]["sec:last_violation"] = resolution 427 428 should_hard_abort = bool( 429 strategy == STRATEGY_HARD_ABORT 430 or is_mutative 431 or retries > 2 432 or (resolution and resolution.should_abort) 433 ) 434 435 if should_hard_abort: 436 if retries > 2 and hasattr(engine, "audit_manager") and engine.audit_manager: 437 from langchain_security_extension.security_core.audit import AuditEvent 438 from langchain_security_extension.security_core.constants import ( 439 EVENT_POLICY_BRUTE_FORCE_DETECTED, 440 PEP_TOOL_INTERCEPTOR, 441 SEVERITY_CRITICAL_ALERT, 442 ) 443 engine.audit_manager.record_event(AuditEvent.create( 444 subject_id=caller_identity.subject_id, 445 tenant_id=caller_identity.tenant_id, 446 action=ACTION_TOOL_EXECUTE, 447 resource=tool_name, 448 decision="HARD_ABORT", 449 diagnostic_reason="Retry budget exceeded: Policy brute force probe detected", 450 severity=SEVERITY_CRITICAL_ALERT, 451 metadata={"retries": retries, "arguments": bound_args}, 452 enforcement_point=PEP_TOOL_INTERCEPTOR, 453 reason_code=EVENT_POLICY_BRUTE_FORCE_DETECTED, 454 )) 455 raise ToolExecutionDeniedError.for_tool( 456 tool_name=tool_name, 457 message=reason_msg, 458 diagnostic_reason=decision.diagnostic_reason, 459 ) 460 else: 461 refusal_payload = { 462 "status": "ACCESS_DENIED", 463 "tool": tool_name, 464 "reason": reason_msg, 465 "diagnostic_reason": decision.diagnostic_reason, 466 } 467 return refusal_payload 468 469 cfg = kwargs.get("config") 470 if isinstance(cfg, dict) and isinstance(cfg.get("configurable"), dict): 471 cfg["configurable"]["sec:last_violation"] = None 472 473 func_kwargs = dict(kwargs) 474 func_kwargs.pop("config", None) 475 func_kwargs.pop("security_identity", None) 476 func_kwargs.pop("runnable_config", None) 477 478 # Re-inject caller SecurityIdentity and RunnableConfig if target function signature declares them 479 try: 480 sig = inspect.signature(func) 481 if "security_identity" in sig.parameters: 482 func_kwargs["security_identity"] = caller_identity 483 if "config" in sig.parameters: 484 func_kwargs["config"] = caller_config 485 elif "runnable_config" in sig.parameters: 486 func_kwargs["runnable_config"] = caller_config 487 except Exception: 488 pass 489 490 if get_pii_scope() == PII_SCOPE_FULL_IN_FLIGHT: 491 sanitizer = get_pii_sanitizer() 492 ctx_in = PiiContext.create( 493 PII_STAGE_TOOL_ARGUMENT, 494 caller_identity.tenant_id, 495 caller_identity.subject_id, 496 tool_name, 497 {"required_roles": required_roles, "min_clearance": min_clearance}, 498 ) 499 func_kwargs = sanitizer.sanitize_payload(func_kwargs, ctx_in) 500 501 result = func(*args, **func_kwargs) 502 503 if get_pii_scope() == PII_SCOPE_FULL_IN_FLIGHT: 504 sanitizer = get_pii_sanitizer() 505 ctx_out = PiiContext.create( 506 PII_STAGE_TOOL_OUTPUT, 507 caller_identity.tenant_id, 508 caller_identity.subject_id, 509 tool_name, 510 {"required_roles": required_roles, "min_clearance": min_clearance}, 511 ) 512 if isinstance(result, str): 513 result = sanitizer.sanitize_text(result, ctx_out) 514 elif isinstance(result, dict): 515 result = sanitizer.sanitize_payload(result, ctx_out) 516 elif isinstance(result, list): 517 result = [ 518 sanitizer.sanitize_payload(item, ctx_out) if isinstance(item, dict) 519 else (sanitizer.sanitize_text(item, ctx_out) if isinstance(item, str) else item) 520 for item in result 521 ] 522 523 return result 524 525 try: 526 import inspect 527 orig_sig = inspect.signature(func) 528 filtered_params = [ 529 p for p in orig_sig.parameters.values() 530 if p.name not in HIDDEN_SECURITY_PARAMS 531 ] 532 wrapper.__signature__ = orig_sig.replace(parameters=filtered_params) 533 except Exception: 534 pass 535 536 return wrapper 537 538 return decorator
Decorator securing tool functions with pre-execution authorization check.
Args: required_roles: List of mandatory role strings for execution. min_clearance: Clearance floor rank required. cel_expression: CEL condition evaluating caller identity and arguments. is_mutative: True if tool modifies system state, triggering HARD_ABORT on failure. *args: Optional positional arguments (pdp_engine, entity_resolver, masked_params). **kwargs: Optional keyword arguments (pdp_engine, entity_resolver, masked_params).
Returns: Callable: Wrapped target tool function with PEP interceptor guard.
82class SecuredToolConfig: 83 """Security configuration specification for secured tool functions. 84 85 Attributes: 86 required_roles (List[str]): List of security roles required to execute the tool. 87 min_clearance (int): Minimum clearance level rank required for tool execution. 88 cel_expression (str): CEL condition expression string evaluating invocation parameters. 89 is_mutative (bool): Flag indicating if tool performs state mutation operations. 90 pdp_engine (PolicyDecisionEngine): PDP engine instance for evaluation. 91 """ 92 93 def __init__( 94 self, 95 required_roles: List[str], 96 min_clearance: int, 97 cel_expression: str, 98 is_mutative: bool, 99 pdp_engine: PolicyDecisionEngine, 100 *args: Any, 101 **kwargs: Any, 102 ): 103 """Initialize SecuredToolConfig. 104 105 Args: 106 required_roles: Roles list. 107 min_clearance: Clearance floor. 108 cel_expression: CEL condition string. 109 is_mutative: Mutative action flag. 110 pdp_engine: PDP decision engine instance. 111 *args: Additional arguments (e.g. masked_params). 112 **kwargs: Additional keyword arguments. 113 """ 114 self._required_roles = list(required_roles) 115 self._min_clearance = min_clearance 116 self._cel_expression = cel_expression 117 self._is_mutative = is_mutative 118 self._pdp_engine = pdp_engine 119 self._masked_params = list(kwargs.get("masked_params", args[0] if len(args) > 0 else [])) 120 121 @classmethod 122 def create( 123 cls, 124 required_roles: List[str], 125 min_clearance: int, 126 cel_expression: str, 127 is_mutative: bool, 128 pdp_engine: PolicyDecisionEngine, 129 *args: Any, 130 **kwargs: Any, 131 ): 132 """Factory method to construct SecuredToolConfig. 133 134 Args: 135 required_roles: List of mandatory role strings for execution. 136 min_clearance: Minimum clearance level rank required. 137 cel_expression: Common Expression Language condition string. 138 is_mutative: True if tool modifies system state. 139 pdp_engine: PDP decision engine instance. 140 *args: Optional positional arguments (masked_params). 141 **kwargs: Optional keyword arguments (masked_params). 142 143 Returns: 144 SecuredToolConfig: New tool security configuration instance. 145 """ 146 return cls( 147 required_roles, 148 min_clearance, 149 cel_expression, 150 is_mutative, 151 pdp_engine, 152 *args, 153 **kwargs, 154 ) 155 156 @property 157 def required_roles(self) -> List[str]: 158 """Get copy of required roles list.""" 159 return list(self._required_roles) 160 161 @property 162 def min_clearance(self) -> int: 163 """Get minimum clearance level rank.""" 164 return self._min_clearance 165 166 @property 167 def cel_expression(self) -> str: 168 """Get CEL parameter constraint expression string.""" 169 return self._cel_expression 170 171 @property 172 def is_mutative(self) -> bool: 173 """Check if tool action is mutative.""" 174 return self._is_mutative 175 176 @property 177 def pdp_engine(self) -> PolicyDecisionEngine: 178 """Get configured PolicyDecisionEngine instance.""" 179 return self._pdp_engine 180 181 @property 182 def masked_params(self) -> List[str]: 183 """Get list of masked parameter names.""" 184 return list(self._masked_params)
Security configuration specification for secured tool functions.
Attributes: required_roles (List[str]): List of security roles required to execute the tool. min_clearance (int): Minimum clearance level rank required for tool execution. cel_expression (str): CEL condition expression string evaluating invocation parameters. is_mutative (bool): Flag indicating if tool performs state mutation operations. pdp_engine (PolicyDecisionEngine): PDP engine instance for evaluation.
93 def __init__( 94 self, 95 required_roles: List[str], 96 min_clearance: int, 97 cel_expression: str, 98 is_mutative: bool, 99 pdp_engine: PolicyDecisionEngine, 100 *args: Any, 101 **kwargs: Any, 102 ): 103 """Initialize SecuredToolConfig. 104 105 Args: 106 required_roles: Roles list. 107 min_clearance: Clearance floor. 108 cel_expression: CEL condition string. 109 is_mutative: Mutative action flag. 110 pdp_engine: PDP decision engine instance. 111 *args: Additional arguments (e.g. masked_params). 112 **kwargs: Additional keyword arguments. 113 """ 114 self._required_roles = list(required_roles) 115 self._min_clearance = min_clearance 116 self._cel_expression = cel_expression 117 self._is_mutative = is_mutative 118 self._pdp_engine = pdp_engine 119 self._masked_params = list(kwargs.get("masked_params", args[0] if len(args) > 0 else []))
Initialize SecuredToolConfig.
Args: required_roles: Roles list. min_clearance: Clearance floor. cel_expression: CEL condition string. is_mutative: Mutative action flag. pdp_engine: PDP decision engine instance. *args: Additional arguments (e.g. masked_params). **kwargs: Additional keyword arguments.
121 @classmethod 122 def create( 123 cls, 124 required_roles: List[str], 125 min_clearance: int, 126 cel_expression: str, 127 is_mutative: bool, 128 pdp_engine: PolicyDecisionEngine, 129 *args: Any, 130 **kwargs: Any, 131 ): 132 """Factory method to construct SecuredToolConfig. 133 134 Args: 135 required_roles: List of mandatory role strings for execution. 136 min_clearance: Minimum clearance level rank required. 137 cel_expression: Common Expression Language condition string. 138 is_mutative: True if tool modifies system state. 139 pdp_engine: PDP decision engine instance. 140 *args: Optional positional arguments (masked_params). 141 **kwargs: Optional keyword arguments (masked_params). 142 143 Returns: 144 SecuredToolConfig: New tool security configuration instance. 145 """ 146 return cls( 147 required_roles, 148 min_clearance, 149 cel_expression, 150 is_mutative, 151 pdp_engine, 152 *args, 153 **kwargs, 154 )
Factory method to construct SecuredToolConfig.
Args: required_roles: List of mandatory role strings for execution. min_clearance: Minimum clearance level rank required. cel_expression: Common Expression Language condition string. is_mutative: True if tool modifies system state. pdp_engine: PDP decision engine instance. *args: Optional positional arguments (masked_params). **kwargs: Optional keyword arguments (masked_params).
Returns: SecuredToolConfig: New tool security configuration instance.
156 @property 157 def required_roles(self) -> List[str]: 158 """Get copy of required roles list.""" 159 return list(self._required_roles)
Get copy of required roles list.
161 @property 162 def min_clearance(self) -> int: 163 """Get minimum clearance level rank.""" 164 return self._min_clearance
Get minimum clearance level rank.
166 @property 167 def cel_expression(self) -> str: 168 """Get CEL parameter constraint expression string.""" 169 return self._cel_expression
Get CEL parameter constraint expression string.
171 @property 172 def is_mutative(self) -> bool: 173 """Check if tool action is mutative.""" 174 return self._is_mutative
Check if tool action is mutative.
27def get_tool_schema(tool: Any) -> Dict[str, Any]: 28 """Extract tool schema concealing internal security parameters from model context. 29 30 Inspects tool function signatures or Pydantic `args_schema` and suppresses internal 31 security parameter names (e.g. security_identity, config, runnable_config) from being 32 exposed in model-facing JSON function schemas. 33 34 Args: 35 tool: Target tool callable, BaseTool instance, or structured tool function. 36 37 Returns: 38 Dict[str, Any]: Model-safe JSON Schema dictionary describing tool arguments. 39 """ 40 tool_name = getattr(tool, "name", None) or getattr(tool, "__name__", "tool") 41 description = getattr(tool, "description", None) or getattr(tool, "__doc__", "") or "" 42 43 properties: Dict[str, Any] = {} 44 required: List[str] = [] 45 46 if hasattr(tool, "args_schema") and tool.args_schema: 47 raw_schema = tool.args_schema.schema() if hasattr(tool.args_schema, "schema") else tool.args_schema 48 if isinstance(raw_schema, dict): 49 props = dict(raw_schema.get("properties", {})) 50 reqs = list(raw_schema.get("required", [])) 51 for hidden in HIDDEN_SECURITY_PARAMS: 52 props.pop(hidden, None) 53 if hidden in reqs: 54 reqs.remove(hidden) 55 properties = props 56 required = reqs 57 else: 58 func = getattr(tool, "func", tool) 59 import inspect 60 try: 61 sig = inspect.signature(func) 62 for param_name, param in sig.parameters.items(): 63 if param_name in ("self", "cls") or param_name in HIDDEN_SECURITY_PARAMS: 64 continue 65 properties[param_name] = {"type": "string", "title": param_name} 66 if param.default is inspect.Parameter.empty: 67 required.append(param_name) 68 except Exception: 69 pass 70 71 return { 72 "name": tool_name, 73 "description": description.strip(), 74 "parameters": { 75 "type": "object", 76 "properties": properties, 77 "required": required, 78 }, 79 "schema_hiding_verified": True, 80 }
Extract tool schema concealing internal security parameters from model context.
Inspects tool function signatures or Pydantic args_schema and suppresses internal
security parameter names (e.g. security_identity, config, runnable_config) from being
exposed in model-facing JSON function schemas.
Args: tool: Target tool callable, BaseTool instance, or structured tool function.
Returns: Dict[str, Any]: Model-safe JSON Schema dictionary describing tool arguments.
148class SecureRetriever(BaseRetriever): 149 """Wraps document retriever components with dynamic pre-filtering, oversampling, and post-pruning PEP. 150 151 Attributes: 152 base_retriever (Any): Underlying vector retriever or search callable. 153 pdp_engine (PolicyDecisionEngine): PDP decision engine instance. 154 """ 155 156 model_config = ConfigDict(arbitrary_types_allowed=True) 157 158 _base_retriever: Any = None 159 _pdp_engine: Any = None 160 _oversampling_factor: float = DEFAULT_OVERSAMPLING_FACTOR 161 _secret_key: Optional[str] = None 162 _store_profile: str = STORE_PROFILE_FULL_AST 163 _secondary_fetch_fn: Any = None 164 _similarity_cutoff: float = DEFAULT_SIMILARITY_CUTOFF 165 _active_config: Any = None 166 167 def __init__( 168 self, 169 base_retriever: Any, 170 pdp_engine: PolicyDecisionEngine, 171 *args: Any, 172 **kwargs: Any, 173 ): 174 """Initialize SecureRetriever. 175 176 Args: 177 base_retriever: Vector retriever or callable. 178 pdp_engine: PDP decision engine instance. 179 *args: Optional positional arguments (oversampling_factor, secret_key, store_profile, secondary_fetch_fn, similarity_cutoff). 180 **kwargs: Optional keyword arguments. 181 """ 182 super().__init__() 183 oversampling_factor = kwargs.get("oversampling_factor", args[0] if len(args) > 0 else DEFAULT_OVERSAMPLING_FACTOR) 184 secret_key = kwargs.get("secret_key", args[1] if len(args) > 1 else None) 185 store_profile = kwargs.get("store_profile", args[2] if len(args) > 2 else STORE_PROFILE_FULL_AST) 186 secondary_fetch_fn = kwargs.get("secondary_fetch_fn", args[3] if len(args) > 3 else None) 187 similarity_cutoff = kwargs.get("similarity_cutoff", args[4] if len(args) > 4 else DEFAULT_SIMILARITY_CUTOFF) 188 189 self._base_retriever = base_retriever 190 self._pdp_engine = pdp_engine 191 self._oversampling_factor = float(oversampling_factor) 192 self._secret_key = secret_key 193 self._store_profile = str(store_profile) 194 self._secondary_fetch_fn = secondary_fetch_fn 195 self._similarity_cutoff = float(similarity_cutoff) 196 self._active_config = None 197 198 @classmethod 199 def wrap( 200 cls, 201 base_retriever: Any, 202 pdp_engine: PolicyDecisionEngine, 203 *args: Any, 204 **kwargs: Any, 205 ): 206 """Factory method to construct a SecureRetriever. 207 208 Args: 209 base_retriever: Base vector store retriever object. 210 pdp_engine: PDP decision engine. 211 *args: Optional positional arguments. 212 **kwargs: Optional keyword arguments. 213 214 Returns: 215 SecureRetriever: New wrapper instance. 216 """ 217 return cls(base_retriever, pdp_engine, *args, **kwargs) 218 219 @property 220 def base_retriever(self) -> Any: 221 """Get the underlying base retriever.""" 222 return self._base_retriever 223 224 @property 225 def pdp_engine(self) -> PolicyDecisionEngine: 226 """Get the configured PolicyDecisionEngine instance.""" 227 return self._pdp_engine 228 229 def build_full_ast(self, identity: Optional[SecurityIdentity]) -> PrefilterAST: 230 """Constructs unconstrained full AST from SecurityIdentity. 231 232 Args: 233 identity: Caller security identity or None for anonymous. 234 235 Returns: 236 PrefilterAST: Full prefilter AST dictionary. 237 """ 238 if identity is None or identity.is_anonymous or identity.subject_id is None or str(identity.subject_id).upper() == ANONYMOUS_SUBJECT_ID: 239 return PrefilterAST({SEC_TENANT_ID: "__DENY_ALL_UNAUTHENTICATED__"}) 240 241 and_clauses: List[Dict[str, Any]] = [ 242 {SEC_TENANT_ID: identity.tenant_id}, 243 {SEC_CLEARANCE_FLOOR: {"$lte": identity.clearance_level}}, 244 ] 245 246 if identity.roles: 247 and_clauses.append({SEC_ALLOWED_ROLES: {"$in": list(identity.roles)}}) 248 249 if identity.department_id: 250 and_clauses.append({ 251 "$or": [ 252 {SEC_DEPARTMENT_ID: identity.department_id}, 253 {SEC_DEPARTMENT_ID: {"$exists": False}}, 254 ] 255 }) 256 257 return PrefilterAST({"$and": and_clauses}) 258 259 def split_predicates( 260 self, 261 full_ast: Dict[str, Any], 262 *args: Any, 263 **kwargs: Any, 264 ) -> Tuple[Dict[str, Any], Dict[str, Any]]: 265 """Splits full AST into push-down filter and retained predicates based on store capability profile. 266 267 Args: 268 full_ast: Complete filter dictionary generated from SecurityIdentity. 269 *args: Optional positional store_profile string. 270 **kwargs: Optional keyword store_profile string. 271 272 Returns: 273 Tuple[Dict[str, Any], Dict[str, Any]]: (push_down_filter, retained_predicates) 274 """ 275 store_profile = str(kwargs.get("store_profile", args[0] if len(args) > 0 else self._store_profile)) 276 277 if store_profile == STORE_PROFILE_NO_FILTER: 278 return PrefilterAST({}), dict(full_ast) 279 280 if store_profile == STORE_PROFILE_CONSTRAINED: 281 clauses = full_ast.get("$and", [full_ast]) if isinstance(full_ast, dict) else [] 282 pushed_clauses: List[Dict[str, Any]] = [] 283 retained: Dict[str, Any] = {} 284 285 for clause in clauses: 286 if not isinstance(clause, dict): 287 continue 288 for key, val in clause.items(): 289 if key == SEC_TENANT_ID: 290 pushed_clauses.append({key: val}) 291 elif key == SEC_DEPARTMENT_ID and not isinstance(val, dict): 292 pushed_clauses.append({key: val}) 293 else: 294 retained[key] = val 295 296 if len(pushed_clauses) == 1: 297 push_down = pushed_clauses[0] 298 elif len(pushed_clauses) > 1: 299 push_down = {"$and": pushed_clauses} 300 else: 301 push_down = {} 302 303 return PrefilterAST(push_down), retained 304 305 return PrefilterAST(dict(full_ast)), {} 306 307 def build_prefilter_ast(self, identity: Optional[SecurityIdentity], *args: Any, **kwargs: Any) -> Dict[str, Any]: 308 """Translates caller identity envelope into dynamic VectorStore metadata filter AST dictionary according to store profile. 309 310 Args: 311 identity: Caller security identity or None for anonymous. 312 *args: Optional positional config dict. 313 **kwargs: Optional keyword config dict. 314 315 Returns: 316 Dict[str, Any]: Prefilter AST dictionary envelope. 317 """ 318 config = kwargs.get("config", args[0] if len(args) > 0 else None) 319 full_ast = self.build_full_ast(identity) 320 push_down, retained = self.split_predicates(full_ast) 321 322 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 323 config["configurable"]["sec:vector_ast_prefilter"] = dict(push_down) 324 config["configurable"]["sec:retained_predicates"] = dict(retained) 325 config["configurable"]["sec:store_profile"] = self._store_profile 326 327 return PrefilterAST(push_down) 328 329 def prune_documents( 330 self, 331 documents: List[SecureDocument], 332 identity: Optional[SecurityIdentity], 333 *args: Any, 334 **kwargs: Any, 335 ) -> List[SecureDocument]: 336 """Post-retrieval pruning inspecting metadata on returned document chunks. Fail-closed if identity is None. 337 338 Args: 339 documents: Candidate list of SecureDocuments. 340 identity: Caller identity or None. 341 *args: Optional positional config dict. 342 **kwargs: Optional keyword config dict. 343 344 Returns: 345 List[SecureDocument]: Filtered list of authorized SecureDocument objects. 346 """ 347 config = kwargs.get("config", args[0] if len(args) > 0 else None) 348 resolved_identity = IdentityBridge.resolve_identity(identity) 349 pruned: List[SecureDocument] = [] 350 audit_manager = getattr(self._pdp_engine, "audit_manager", None) 351 352 for doc in documents: 353 metadata = doc.metadata 354 355 # Optional HMAC Signature verification 356 if self._secret_key and SEC_ENVELOPE_HASH in metadata: 357 from langchain_security_extension.security_adapter_langchain.ingestion import compute_envelope_hmac 358 expected = compute_envelope_hmac(metadata, self._secret_key) 359 if metadata[SEC_ENVELOPE_HASH] != expected: 360 if audit_manager: 361 audit_manager.record_event(AuditEvent.create( 362 subject_id=resolved_identity.subject_id, 363 tenant_id=resolved_identity.tenant_id, 364 action=ACTION_CHUNK_RETRIEVE, 365 resource=str(metadata.get("source", "document_chunk")), 366 decision="DENY", 367 diagnostic_reason="POLICY_TAMPER_DETECTED | HMAC_MISMATCH: Envelope HMAC signature mismatch", 368 severity=SEVERITY_CRITICAL_ALERT, 369 metadata={"doc_metadata": metadata}, 370 enforcement_point=PEP_RAG_FILTER, 371 reason_code=EVENT_POLICY_TAMPER_DETECTED, 372 )) 373 continue # Fail-closed tampering detection 374 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 375 config["configurable"]["sec:envelope_verified"] = True 376 377 # Mandatory envelope verification: verify mandatory security envelope tags 378 doc_tenant = metadata.get(SEC_TENANT_ID) 379 has_tenant = bool(doc_tenant) 380 has_classification = bool( 381 metadata.get(SEC_CLASSIFICATION_LEVEL) 382 or metadata.get(SEC_CLEARANCE_FLOOR) is not None 383 or metadata.get("sec:clearance_level") is not None 384 ) 385 if not (has_tenant and has_classification): 386 if audit_manager: 387 audit_manager.record_event(AuditEvent.create( 388 subject_id=resolved_identity.subject_id, 389 tenant_id=resolved_identity.tenant_id, 390 action=ACTION_CHUNK_RETRIEVE, 391 resource=str(metadata.get("source", "document_chunk")), 392 decision="PRUNED", 393 diagnostic_reason="Missing mandatory security metadata: tenant_id or classification", 394 severity=SEVERITY_NOTICE, 395 metadata={"doc_metadata": metadata}, 396 enforcement_point=PEP_RAG_FILTER, 397 reason_code=EVENT_RAG_UNAUTHORIZED_PRUNING, 398 )) 399 continue 400 401 doc_clearance = int(metadata.get(SEC_CLEARANCE_FLOOR, metadata.get("sec:clearance_level", CLASSIFICATION_TO_RANK.get(str(metadata.get(SEC_CLASSIFICATION_LEVEL, "")).upper(), 0)))) 402 doc_roles = metadata.get(SEC_ALLOWED_ROLES, metadata.get(SEC_REQUIRED_ROLES, [])) 403 if isinstance(doc_roles, str): 404 doc_roles = [r.strip() for r in doc_roles.split(",") if r.strip()] 405 doc_quarantined = bool(metadata.get(SEC_QUARANTINED, False)) 406 407 request = PolicyEvaluationRequest.create( 408 subject=resolved_identity, 409 action=ACTION_CHUNK_RETRIEVE, 410 resource={ 411 "target_id": str(metadata.get("source", "document_chunk")), 412 "tenant_id": doc_tenant, 413 "min_clearance": doc_clearance, 414 "required_roles": doc_roles, 415 SEC_QUARANTINED: doc_quarantined, 416 }, 417 context={"config": config} if config else {}, 418 ) 419 420 decision = self._pdp_engine.evaluate(request) 421 if decision.is_permitted: 422 pruned.append(doc) 423 else: 424 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 425 pruned_titles = config["configurable"].setdefault("sec:pruned_doc_titles", []) 426 first_line = doc.page_content.splitlines()[0].lstrip("#").strip() if doc.page_content else "" 427 if first_line and first_line not in pruned_titles: 428 pruned_titles.append(first_line) 429 if audit_manager: 430 audit_manager.record_event(AuditEvent.create( 431 subject_id=resolved_identity.subject_id, 432 tenant_id=resolved_identity.tenant_id, 433 action=ACTION_CHUNK_RETRIEVE, 434 resource=str(metadata.get("source", "document_chunk")), 435 decision="PRUNED", 436 diagnostic_reason=decision.diagnostic_reason, 437 severity=SEVERITY_NOTICE, 438 metadata={"doc_metadata": metadata}, 439 enforcement_point=PEP_RAG_FILTER, 440 reason_code=EVENT_RAG_UNAUTHORIZED_PRUNING, 441 )) 442 443 return pruned 444 445 def prune_candidates( 446 self, 447 candidates: List[Any], 448 identity: Optional[SecurityIdentity], 449 *args: Any, 450 **kwargs: Any, 451 ) -> List[Any]: 452 """Encapsulates candidate wrapping in SecureDocument, oversampling fetch sizing, AST evaluation, adaptive secondary paging, and returning original candidate objects. 453 454 Args: 455 candidates: Input raw candidates list. 456 identity: Caller security identity. 457 *args: Optional positional arguments (top_k, config). 458 **kwargs: Optional keyword arguments (top_k, config). 459 460 Returns: 461 List[Any]: Filtered list of up to top_k authorized candidate objects. 462 """ 463 top_k = int(kwargs.get("top_k", args[0] if len(args) > 0 else DEFAULT_TOP_K_CANDIDATES)) 464 config = kwargs.get("config", args[1] if len(args) > 1 else None) 465 466 resolved_identity = IdentityBridge.resolve_identity(identity) 467 self.build_prefilter_ast(resolved_identity, config=config) 468 469 if not candidates: 470 return [] 471 472 audit_manager = getattr(self._pdp_engine, "audit_manager", None) 473 474 raw_k_fetch = int(top_k * self._oversampling_factor) 475 k_fetch = min(max(raw_k_fetch, top_k), DEFAULT_MAX_FETCH_CAP) 476 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 477 config["configurable"]["sec:oversampled_fetch_k"] = k_fetch 478 479 sec_docs: List[SecureDocument] = [] 480 doc_map: Dict[int, Any] = {} 481 482 # Filter by similarity score cutoff if score is present on candidate 483 filtered_candidates = [] 484 for cand in candidates: 485 score = getattr(cand, "score", getattr(cand, "similarity_score", None)) 486 if score is not None and self._similarity_cutoff > 0.0 and float(score) < self._similarity_cutoff: 487 continue 488 filtered_candidates.append(cand) 489 490 for cand in filtered_candidates: 491 if isinstance(cand, SecureDocument): 492 doc = cand 493 elif hasattr(cand, "page_content") and hasattr(cand, "metadata"): 494 doc = SecureDocument.create(page_content=cand.page_content, metadata=cand.metadata) 495 elif hasattr(cand, "clearance_level") and hasattr(cand, "required_roles"): 496 meta = { 497 SEC_TENANT_ID: getattr(cand, "tenant_id", resolved_identity.tenant_id), 498 SEC_CLEARANCE_FLOOR: getattr(cand, "clearance_level", 0), 499 SEC_ALLOWED_ROLES: getattr(cand, "required_roles", []), 500 "source": getattr(cand, "source", ""), 501 } 502 if hasattr(cand, "quarantined"): 503 meta[SEC_QUARANTINED] = getattr(cand, "quarantined") 504 if hasattr(cand, "envelope_hash"): 505 meta[SEC_ENVELOPE_HASH] = getattr(cand, "envelope_hash") 506 doc = SecureDocument.create(page_content=getattr(cand, "text", str(cand)), metadata=meta) 507 else: 508 meta = getattr(cand, "metadata", {}) 509 content = getattr(cand, "page_content", getattr(cand, "text", str(cand))) 510 doc = SecureDocument.create(page_content=content, metadata=meta) 511 512 sec_docs.append(doc) 513 doc_map[id(doc)] = cand 514 515 # First pass oversampled pruning 516 first_batch = sec_docs[:k_fetch] 517 pruned_sec_docs = self.prune_documents(first_batch, resolved_identity, config=config) 518 519 # Adaptive secondary paging fallback if post-pruning count < top_k 520 secondary_attempts = 0 521 current_offset = k_fetch 522 while len(pruned_sec_docs) < top_k and secondary_attempts < DEFAULT_MAX_SECONDARY_FETCHES: 523 if self._secondary_fetch_fn: 524 more_cands = self._secondary_fetch_fn(current_offset, k_fetch) 525 if not more_cands: 526 break 527 more_sec_docs = [] 528 for cand in more_cands: 529 score = getattr(cand, "score", getattr(cand, "similarity_score", None)) 530 if score is not None and self._similarity_cutoff > 0.0 and float(score) < self._similarity_cutoff: 531 continue 532 if hasattr(cand, "page_content") and hasattr(cand, "metadata"): 533 doc = SecureDocument.create(page_content=cand.page_content, metadata=cand.metadata) 534 else: 535 doc = SecureDocument.create(page_content=str(cand), metadata=getattr(cand, "metadata", {})) 536 more_sec_docs.append(doc) 537 doc_map[id(doc)] = cand 538 sec_pruned = self.prune_documents(more_sec_docs, resolved_identity, config=config) 539 pruned_sec_docs.extend(sec_pruned) 540 elif current_offset < len(sec_docs): 541 next_batch = sec_docs[current_offset:current_offset + k_fetch] 542 if not next_batch: 543 break 544 sec_pruned = self.prune_documents(next_batch, resolved_identity, config=config) 545 pruned_sec_docs.extend(sec_pruned) 546 else: 547 break 548 current_offset += k_fetch 549 secondary_attempts += 1 550 551 permitted_candidates = [doc_map[id(doc)] for doc in pruned_sec_docs if id(doc) in doc_map] 552 553 if len(permitted_candidates) < top_k and audit_manager: 554 audit_manager.record_event(AuditEvent.create( 555 subject_id=resolved_identity.subject_id, 556 tenant_id=resolved_identity.tenant_id, 557 action=ACTION_CHUNK_RETRIEVE, 558 resource="vector_retriever", 559 decision="STARVATION", 560 diagnostic_reason=f"Post-pruning candidate count ({len(permitted_candidates)}) < target top_k ({top_k})", 561 severity=SEVERITY_WARNING, 562 metadata={"requested_k": top_k, "returned_count": len(permitted_candidates)}, 563 enforcement_point=PEP_RAG_FILTER, 564 reason_code=EVENT_RAG_RESULT_STARVATION, 565 )) 566 567 final_candidates = permitted_candidates[:top_k] 568 if get_pii_scope() == PII_SCOPE_FULL_IN_FLIGHT: 569 sanitizer = get_pii_sanitizer() 570 ctx = PiiContext.create( 571 PII_STAGE_RETRIEVAL_DOCUMENT, 572 resolved_identity.tenant_id, 573 resolved_identity.subject_id, 574 "vector_retriever", 575 {"requested_k": top_k, "returned_count": len(final_candidates)}, 576 ) 577 for doc in final_candidates: 578 if hasattr(doc, "page_content") and isinstance(doc.page_content, str): 579 doc.page_content = sanitizer.sanitize_text(doc.page_content, ctx) 580 581 return final_candidates 582 583 def invoke( 584 self, 585 input: str, 586 config: Optional[Any] = None, 587 **kwargs: Any, 588 ) -> List[SecureDocument]: 589 """Invoke the retriever synchronously capturing RunnableConfig for identity extraction and AST filtering. 590 591 Args: 592 input: Query string. 593 config: Optional RunnableConfig dictionary containing caller identity in configurable. 594 **kwargs: Additional retriever arguments. 595 596 Returns: 597 List[SecureDocument]: List of authorized SecureDocument objects. 598 """ 599 self._active_config = config 600 try: 601 return super().invoke(input, config=config, **kwargs) 602 finally: 603 self._active_config = None 604 605 def _get_relevant_documents( 606 self, 607 query: str, 608 *, 609 run_manager: Optional[CallbackManagerForRetrieverRun] = None, 610 **kwargs: Any, 611 ) -> List[SecureDocument]: 612 """Retrieve relevant documents applying security AST filtering, oversampling, and post-pruning. 613 614 1. Extract SecurityIdentity from run_manager.config / RunnableConfig. 615 2. Generate AST filter dictionary according to store capability profile. 616 3. Delegate fetch to self.base_retriever.get_relevant_documents(...) with search_kwargs={"filter": ast_filter}. 617 4. Execute post-retrieval pruning and adaptive secondary paging. 618 619 Args: 620 query: Query string. 621 run_manager: Callback manager for retriever execution providing context and config. 622 **kwargs: Additional retriever arguments (e.g. top_k). 623 624 Returns: 625 List[SecureDocument]: Pruned and authorized documents. 626 """ 627 run_config = getattr(run_manager, "config", None) or self._active_config 628 identity = IdentityBridge.extract_from_config(run_config) 629 resolved_identity = IdentityBridge.resolve_identity(identity) 630 631 ast_filter = self.build_prefilter_ast(resolved_identity, config=run_config) 632 633 top_k = int(kwargs.get("top_k", DEFAULT_TOP_K_CANDIDATES)) 634 raw_k_fetch = int(top_k * self._oversampling_factor) 635 k_fetch = min(max(raw_k_fetch, top_k), DEFAULT_MAX_FETCH_CAP) 636 if isinstance(run_config, dict) and isinstance(run_config.get("configurable"), dict): 637 run_config["configurable"]["sec:oversampled_fetch_k"] = k_fetch 638 639 candidates: List[Any] = [] 640 if hasattr(self._base_retriever, "get_relevant_documents"): 641 candidates = self._base_retriever.get_relevant_documents( 642 query, 643 search_kwargs={"filter": ast_filter, "k": k_fetch}, 644 ) 645 elif hasattr(self._base_retriever, "invoke"): 646 candidates = self._base_retriever.invoke( 647 query, 648 search_kwargs={"filter": ast_filter, "k": k_fetch}, 649 ) 650 elif hasattr(self._base_retriever, "query"): 651 where_arg = _sanitize_chroma_filter(dict(ast_filter)) if ast_filter else None 652 query_kwargs: Dict[str, Any] = {"n_results": k_fetch} 653 if where_arg: 654 query_kwargs["where"] = where_arg 655 raw_res = self._base_retriever.query(query_texts=[query], **query_kwargs) 656 docs = raw_res.get("documents", [[]])[0] 657 metas = raw_res.get("metadatas", [[]])[0] 658 ids = raw_res.get("ids", [[]])[0] 659 for doc_id, doc_text, meta in zip(ids, docs, metas): 660 clean_meta = dict(meta or {}) 661 roles_val = clean_meta.get(SEC_ALLOWED_ROLES) 662 if isinstance(roles_val, str) and roles_val: 663 clean_meta[SEC_ALLOWED_ROLES] = [r.strip() for r in roles_val.split(",") if r.strip()] 664 req_roles = clean_meta.get(SEC_REQUIRED_ROLES) 665 if isinstance(req_roles, str) and req_roles: 666 clean_meta[SEC_REQUIRED_ROLES] = [r.strip() for r in req_roles.split(",") if r.strip()] 667 candidates.append(SecureDocument.create(page_content=doc_text, metadata=clean_meta)) 668 elif callable(self._base_retriever): 669 candidates = self._base_retriever(query, ast_filter, k_fetch) 670 671 return self.prune_candidates(candidates, resolved_identity, top_k, config=run_config) 672 673 def get_relevant_documents( 674 self, 675 query: str, 676 identity: Optional[SecurityIdentity] = None, 677 *args: Any, 678 **kwargs: Any, 679 ) -> List[SecureDocument]: 680 """Executes retrieval with pre-filtering, oversampling, and post-pruning. 681 682 Args: 683 query: Vector store text query string. 684 identity: Caller security identity or None. 685 *args: Optional positional config dict. 686 **kwargs: Optional keyword config dict. 687 688 Returns: 689 List[SecureDocument]: List of authorized SecureDocuments. 690 """ 691 config = kwargs.get("config", args[0] if len(args) > 0 else None) 692 filter_ast = self.build_prefilter_ast(identity, config=config) 693 694 if hasattr(self._base_retriever, "get_relevant_documents"): 695 raw_docs = self._base_retriever.get_relevant_documents(query, search_kwargs={"filter": filter_ast}) 696 elif callable(self._base_retriever): 697 raw_docs = self._base_retriever(query, filter_ast) 698 else: 699 raw_docs = [] 700 701 converted_docs: List[SecureDocument] = [] 702 for raw in raw_docs: 703 if isinstance(raw, SecureDocument): 704 converted_docs.append(raw) 705 elif hasattr(raw, "page_content") and hasattr(raw, "metadata"): 706 converted_docs.append(SecureDocument.create(page_content=raw.page_content, metadata=raw.metadata)) 707 elif isinstance(raw, dict): 708 converted_docs.append(SecureDocument.create(page_content=raw.get("page_content", ""), metadata=raw.get("metadata", {}))) 709 710 return self.prune_documents(converted_docs, identity, config=config) 711 712 def search( 713 self, 714 query: str, 715 identity: Optional[SecurityIdentity] = None, 716 *args: Any, 717 **kwargs: Any, 718 ) -> List[Any]: 719 """Executes vector similarity search with AST push-down, oversampling, and post-pruning. 720 721 Args: 722 query: Vector store text query string. 723 identity: Caller security identity or None. 724 *args: Optional positional arguments (top_k, config). 725 **kwargs: Optional keyword arguments (top_k, config). 726 727 Returns: 728 List[Any]: Filtered list of authorized candidates. 729 """ 730 top_k: int = int(args[0]) if len(args) > 0 else int(kwargs.get("top_k", DEFAULT_TOP_K_CANDIDATES)) 731 config: Optional[Dict[str, Any]] = args[1] if len(args) > 1 else kwargs.get("config") 732 733 resolved_identity = IdentityBridge.resolve_identity(identity) 734 push_down_filter = self.build_prefilter_ast(resolved_identity, config=config) 735 736 raw_k_fetch = int(top_k * self._oversampling_factor) 737 k_fetch = min(max(raw_k_fetch, top_k), DEFAULT_MAX_FETCH_CAP) 738 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 739 config["configurable"]["sec:oversampled_fetch_k"] = k_fetch 740 741 candidates: List[Any] = [] 742 if hasattr(self._base_retriever, "query"): 743 where_arg = _sanitize_chroma_filter(dict(push_down_filter)) if push_down_filter else None 744 query_kwargs: Dict[str, Any] = {"n_results": k_fetch} 745 if where_arg: 746 query_kwargs["where"] = where_arg 747 raw_res = self._base_retriever.query(query_texts=[query], **query_kwargs) 748 docs = raw_res.get("documents", [[]])[0] 749 metas = raw_res.get("metadatas", [[]])[0] 750 ids = raw_res.get("ids", [[]])[0] 751 for doc_id, doc_text, meta in zip(ids, docs, metas): 752 clean_meta = dict(meta or {}) 753 roles_val = clean_meta.get(SEC_ALLOWED_ROLES) 754 if isinstance(roles_val, str) and roles_val: 755 clean_meta[SEC_ALLOWED_ROLES] = [r.strip() for r in roles_val.split(",") if r.strip()] 756 req_roles = clean_meta.get(SEC_REQUIRED_ROLES) 757 if isinstance(req_roles, str) and req_roles: 758 clean_meta[SEC_REQUIRED_ROLES] = [r.strip() for r in req_roles.split(",") if r.strip()] 759 candidates.append(SecureDocument.create(page_content=doc_text, metadata=clean_meta)) 760 elif hasattr(self._base_retriever, "get_relevant_documents"): 761 candidates = self._base_retriever.get_relevant_documents(query, search_kwargs={"filter": push_down_filter, "k": k_fetch}) 762 elif callable(self._base_retriever): 763 candidates = self._base_retriever(query, push_down_filter, k_fetch) 764 765 return self.prune_candidates(candidates, resolved_identity, top_k, config=config)
Wraps document retriever components with dynamic pre-filtering, oversampling, and post-pruning PEP.
Attributes: base_retriever (Any): Underlying vector retriever or search callable. pdp_engine (PolicyDecisionEngine): PDP decision engine instance.
198 @classmethod 199 def wrap( 200 cls, 201 base_retriever: Any, 202 pdp_engine: PolicyDecisionEngine, 203 *args: Any, 204 **kwargs: Any, 205 ): 206 """Factory method to construct a SecureRetriever. 207 208 Args: 209 base_retriever: Base vector store retriever object. 210 pdp_engine: PDP decision engine. 211 *args: Optional positional arguments. 212 **kwargs: Optional keyword arguments. 213 214 Returns: 215 SecureRetriever: New wrapper instance. 216 """ 217 return cls(base_retriever, pdp_engine, *args, **kwargs)
Factory method to construct a SecureRetriever.
Args: base_retriever: Base vector store retriever object. pdp_engine: PDP decision engine. *args: Optional positional arguments. **kwargs: Optional keyword arguments.
Returns: SecureRetriever: New wrapper instance.
219 @property 220 def base_retriever(self) -> Any: 221 """Get the underlying base retriever.""" 222 return self._base_retriever
Get the underlying base retriever.
224 @property 225 def pdp_engine(self) -> PolicyDecisionEngine: 226 """Get the configured PolicyDecisionEngine instance.""" 227 return self._pdp_engine
Get the configured PolicyDecisionEngine instance.
229 def build_full_ast(self, identity: Optional[SecurityIdentity]) -> PrefilterAST: 230 """Constructs unconstrained full AST from SecurityIdentity. 231 232 Args: 233 identity: Caller security identity or None for anonymous. 234 235 Returns: 236 PrefilterAST: Full prefilter AST dictionary. 237 """ 238 if identity is None or identity.is_anonymous or identity.subject_id is None or str(identity.subject_id).upper() == ANONYMOUS_SUBJECT_ID: 239 return PrefilterAST({SEC_TENANT_ID: "__DENY_ALL_UNAUTHENTICATED__"}) 240 241 and_clauses: List[Dict[str, Any]] = [ 242 {SEC_TENANT_ID: identity.tenant_id}, 243 {SEC_CLEARANCE_FLOOR: {"$lte": identity.clearance_level}}, 244 ] 245 246 if identity.roles: 247 and_clauses.append({SEC_ALLOWED_ROLES: {"$in": list(identity.roles)}}) 248 249 if identity.department_id: 250 and_clauses.append({ 251 "$or": [ 252 {SEC_DEPARTMENT_ID: identity.department_id}, 253 {SEC_DEPARTMENT_ID: {"$exists": False}}, 254 ] 255 }) 256 257 return PrefilterAST({"$and": and_clauses})
Constructs unconstrained full AST from SecurityIdentity.
Args: identity: Caller security identity or None for anonymous.
Returns: PrefilterAST: Full prefilter AST dictionary.
259 def split_predicates( 260 self, 261 full_ast: Dict[str, Any], 262 *args: Any, 263 **kwargs: Any, 264 ) -> Tuple[Dict[str, Any], Dict[str, Any]]: 265 """Splits full AST into push-down filter and retained predicates based on store capability profile. 266 267 Args: 268 full_ast: Complete filter dictionary generated from SecurityIdentity. 269 *args: Optional positional store_profile string. 270 **kwargs: Optional keyword store_profile string. 271 272 Returns: 273 Tuple[Dict[str, Any], Dict[str, Any]]: (push_down_filter, retained_predicates) 274 """ 275 store_profile = str(kwargs.get("store_profile", args[0] if len(args) > 0 else self._store_profile)) 276 277 if store_profile == STORE_PROFILE_NO_FILTER: 278 return PrefilterAST({}), dict(full_ast) 279 280 if store_profile == STORE_PROFILE_CONSTRAINED: 281 clauses = full_ast.get("$and", [full_ast]) if isinstance(full_ast, dict) else [] 282 pushed_clauses: List[Dict[str, Any]] = [] 283 retained: Dict[str, Any] = {} 284 285 for clause in clauses: 286 if not isinstance(clause, dict): 287 continue 288 for key, val in clause.items(): 289 if key == SEC_TENANT_ID: 290 pushed_clauses.append({key: val}) 291 elif key == SEC_DEPARTMENT_ID and not isinstance(val, dict): 292 pushed_clauses.append({key: val}) 293 else: 294 retained[key] = val 295 296 if len(pushed_clauses) == 1: 297 push_down = pushed_clauses[0] 298 elif len(pushed_clauses) > 1: 299 push_down = {"$and": pushed_clauses} 300 else: 301 push_down = {} 302 303 return PrefilterAST(push_down), retained 304 305 return PrefilterAST(dict(full_ast)), {}
Splits full AST into push-down filter and retained predicates based on store capability profile.
Args: full_ast: Complete filter dictionary generated from SecurityIdentity. *args: Optional positional store_profile string. **kwargs: Optional keyword store_profile string.
Returns: Tuple[Dict[str, Any], Dict[str, Any]]: (push_down_filter, retained_predicates)
307 def build_prefilter_ast(self, identity: Optional[SecurityIdentity], *args: Any, **kwargs: Any) -> Dict[str, Any]: 308 """Translates caller identity envelope into dynamic VectorStore metadata filter AST dictionary according to store profile. 309 310 Args: 311 identity: Caller security identity or None for anonymous. 312 *args: Optional positional config dict. 313 **kwargs: Optional keyword config dict. 314 315 Returns: 316 Dict[str, Any]: Prefilter AST dictionary envelope. 317 """ 318 config = kwargs.get("config", args[0] if len(args) > 0 else None) 319 full_ast = self.build_full_ast(identity) 320 push_down, retained = self.split_predicates(full_ast) 321 322 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 323 config["configurable"]["sec:vector_ast_prefilter"] = dict(push_down) 324 config["configurable"]["sec:retained_predicates"] = dict(retained) 325 config["configurable"]["sec:store_profile"] = self._store_profile 326 327 return PrefilterAST(push_down)
Translates caller identity envelope into dynamic VectorStore metadata filter AST dictionary according to store profile.
Args: identity: Caller security identity or None for anonymous. *args: Optional positional config dict. **kwargs: Optional keyword config dict.
Returns: Dict[str, Any]: Prefilter AST dictionary envelope.
329 def prune_documents( 330 self, 331 documents: List[SecureDocument], 332 identity: Optional[SecurityIdentity], 333 *args: Any, 334 **kwargs: Any, 335 ) -> List[SecureDocument]: 336 """Post-retrieval pruning inspecting metadata on returned document chunks. Fail-closed if identity is None. 337 338 Args: 339 documents: Candidate list of SecureDocuments. 340 identity: Caller identity or None. 341 *args: Optional positional config dict. 342 **kwargs: Optional keyword config dict. 343 344 Returns: 345 List[SecureDocument]: Filtered list of authorized SecureDocument objects. 346 """ 347 config = kwargs.get("config", args[0] if len(args) > 0 else None) 348 resolved_identity = IdentityBridge.resolve_identity(identity) 349 pruned: List[SecureDocument] = [] 350 audit_manager = getattr(self._pdp_engine, "audit_manager", None) 351 352 for doc in documents: 353 metadata = doc.metadata 354 355 # Optional HMAC Signature verification 356 if self._secret_key and SEC_ENVELOPE_HASH in metadata: 357 from langchain_security_extension.security_adapter_langchain.ingestion import compute_envelope_hmac 358 expected = compute_envelope_hmac(metadata, self._secret_key) 359 if metadata[SEC_ENVELOPE_HASH] != expected: 360 if audit_manager: 361 audit_manager.record_event(AuditEvent.create( 362 subject_id=resolved_identity.subject_id, 363 tenant_id=resolved_identity.tenant_id, 364 action=ACTION_CHUNK_RETRIEVE, 365 resource=str(metadata.get("source", "document_chunk")), 366 decision="DENY", 367 diagnostic_reason="POLICY_TAMPER_DETECTED | HMAC_MISMATCH: Envelope HMAC signature mismatch", 368 severity=SEVERITY_CRITICAL_ALERT, 369 metadata={"doc_metadata": metadata}, 370 enforcement_point=PEP_RAG_FILTER, 371 reason_code=EVENT_POLICY_TAMPER_DETECTED, 372 )) 373 continue # Fail-closed tampering detection 374 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 375 config["configurable"]["sec:envelope_verified"] = True 376 377 # Mandatory envelope verification: verify mandatory security envelope tags 378 doc_tenant = metadata.get(SEC_TENANT_ID) 379 has_tenant = bool(doc_tenant) 380 has_classification = bool( 381 metadata.get(SEC_CLASSIFICATION_LEVEL) 382 or metadata.get(SEC_CLEARANCE_FLOOR) is not None 383 or metadata.get("sec:clearance_level") is not None 384 ) 385 if not (has_tenant and has_classification): 386 if audit_manager: 387 audit_manager.record_event(AuditEvent.create( 388 subject_id=resolved_identity.subject_id, 389 tenant_id=resolved_identity.tenant_id, 390 action=ACTION_CHUNK_RETRIEVE, 391 resource=str(metadata.get("source", "document_chunk")), 392 decision="PRUNED", 393 diagnostic_reason="Missing mandatory security metadata: tenant_id or classification", 394 severity=SEVERITY_NOTICE, 395 metadata={"doc_metadata": metadata}, 396 enforcement_point=PEP_RAG_FILTER, 397 reason_code=EVENT_RAG_UNAUTHORIZED_PRUNING, 398 )) 399 continue 400 401 doc_clearance = int(metadata.get(SEC_CLEARANCE_FLOOR, metadata.get("sec:clearance_level", CLASSIFICATION_TO_RANK.get(str(metadata.get(SEC_CLASSIFICATION_LEVEL, "")).upper(), 0)))) 402 doc_roles = metadata.get(SEC_ALLOWED_ROLES, metadata.get(SEC_REQUIRED_ROLES, [])) 403 if isinstance(doc_roles, str): 404 doc_roles = [r.strip() for r in doc_roles.split(",") if r.strip()] 405 doc_quarantined = bool(metadata.get(SEC_QUARANTINED, False)) 406 407 request = PolicyEvaluationRequest.create( 408 subject=resolved_identity, 409 action=ACTION_CHUNK_RETRIEVE, 410 resource={ 411 "target_id": str(metadata.get("source", "document_chunk")), 412 "tenant_id": doc_tenant, 413 "min_clearance": doc_clearance, 414 "required_roles": doc_roles, 415 SEC_QUARANTINED: doc_quarantined, 416 }, 417 context={"config": config} if config else {}, 418 ) 419 420 decision = self._pdp_engine.evaluate(request) 421 if decision.is_permitted: 422 pruned.append(doc) 423 else: 424 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 425 pruned_titles = config["configurable"].setdefault("sec:pruned_doc_titles", []) 426 first_line = doc.page_content.splitlines()[0].lstrip("#").strip() if doc.page_content else "" 427 if first_line and first_line not in pruned_titles: 428 pruned_titles.append(first_line) 429 if audit_manager: 430 audit_manager.record_event(AuditEvent.create( 431 subject_id=resolved_identity.subject_id, 432 tenant_id=resolved_identity.tenant_id, 433 action=ACTION_CHUNK_RETRIEVE, 434 resource=str(metadata.get("source", "document_chunk")), 435 decision="PRUNED", 436 diagnostic_reason=decision.diagnostic_reason, 437 severity=SEVERITY_NOTICE, 438 metadata={"doc_metadata": metadata}, 439 enforcement_point=PEP_RAG_FILTER, 440 reason_code=EVENT_RAG_UNAUTHORIZED_PRUNING, 441 )) 442 443 return pruned
Post-retrieval pruning inspecting metadata on returned document chunks. Fail-closed if identity is None.
Args: documents: Candidate list of SecureDocuments. identity: Caller identity or None. *args: Optional positional config dict. **kwargs: Optional keyword config dict.
Returns: List[SecureDocument]: Filtered list of authorized SecureDocument objects.
445 def prune_candidates( 446 self, 447 candidates: List[Any], 448 identity: Optional[SecurityIdentity], 449 *args: Any, 450 **kwargs: Any, 451 ) -> List[Any]: 452 """Encapsulates candidate wrapping in SecureDocument, oversampling fetch sizing, AST evaluation, adaptive secondary paging, and returning original candidate objects. 453 454 Args: 455 candidates: Input raw candidates list. 456 identity: Caller security identity. 457 *args: Optional positional arguments (top_k, config). 458 **kwargs: Optional keyword arguments (top_k, config). 459 460 Returns: 461 List[Any]: Filtered list of up to top_k authorized candidate objects. 462 """ 463 top_k = int(kwargs.get("top_k", args[0] if len(args) > 0 else DEFAULT_TOP_K_CANDIDATES)) 464 config = kwargs.get("config", args[1] if len(args) > 1 else None) 465 466 resolved_identity = IdentityBridge.resolve_identity(identity) 467 self.build_prefilter_ast(resolved_identity, config=config) 468 469 if not candidates: 470 return [] 471 472 audit_manager = getattr(self._pdp_engine, "audit_manager", None) 473 474 raw_k_fetch = int(top_k * self._oversampling_factor) 475 k_fetch = min(max(raw_k_fetch, top_k), DEFAULT_MAX_FETCH_CAP) 476 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 477 config["configurable"]["sec:oversampled_fetch_k"] = k_fetch 478 479 sec_docs: List[SecureDocument] = [] 480 doc_map: Dict[int, Any] = {} 481 482 # Filter by similarity score cutoff if score is present on candidate 483 filtered_candidates = [] 484 for cand in candidates: 485 score = getattr(cand, "score", getattr(cand, "similarity_score", None)) 486 if score is not None and self._similarity_cutoff > 0.0 and float(score) < self._similarity_cutoff: 487 continue 488 filtered_candidates.append(cand) 489 490 for cand in filtered_candidates: 491 if isinstance(cand, SecureDocument): 492 doc = cand 493 elif hasattr(cand, "page_content") and hasattr(cand, "metadata"): 494 doc = SecureDocument.create(page_content=cand.page_content, metadata=cand.metadata) 495 elif hasattr(cand, "clearance_level") and hasattr(cand, "required_roles"): 496 meta = { 497 SEC_TENANT_ID: getattr(cand, "tenant_id", resolved_identity.tenant_id), 498 SEC_CLEARANCE_FLOOR: getattr(cand, "clearance_level", 0), 499 SEC_ALLOWED_ROLES: getattr(cand, "required_roles", []), 500 "source": getattr(cand, "source", ""), 501 } 502 if hasattr(cand, "quarantined"): 503 meta[SEC_QUARANTINED] = getattr(cand, "quarantined") 504 if hasattr(cand, "envelope_hash"): 505 meta[SEC_ENVELOPE_HASH] = getattr(cand, "envelope_hash") 506 doc = SecureDocument.create(page_content=getattr(cand, "text", str(cand)), metadata=meta) 507 else: 508 meta = getattr(cand, "metadata", {}) 509 content = getattr(cand, "page_content", getattr(cand, "text", str(cand))) 510 doc = SecureDocument.create(page_content=content, metadata=meta) 511 512 sec_docs.append(doc) 513 doc_map[id(doc)] = cand 514 515 # First pass oversampled pruning 516 first_batch = sec_docs[:k_fetch] 517 pruned_sec_docs = self.prune_documents(first_batch, resolved_identity, config=config) 518 519 # Adaptive secondary paging fallback if post-pruning count < top_k 520 secondary_attempts = 0 521 current_offset = k_fetch 522 while len(pruned_sec_docs) < top_k and secondary_attempts < DEFAULT_MAX_SECONDARY_FETCHES: 523 if self._secondary_fetch_fn: 524 more_cands = self._secondary_fetch_fn(current_offset, k_fetch) 525 if not more_cands: 526 break 527 more_sec_docs = [] 528 for cand in more_cands: 529 score = getattr(cand, "score", getattr(cand, "similarity_score", None)) 530 if score is not None and self._similarity_cutoff > 0.0 and float(score) < self._similarity_cutoff: 531 continue 532 if hasattr(cand, "page_content") and hasattr(cand, "metadata"): 533 doc = SecureDocument.create(page_content=cand.page_content, metadata=cand.metadata) 534 else: 535 doc = SecureDocument.create(page_content=str(cand), metadata=getattr(cand, "metadata", {})) 536 more_sec_docs.append(doc) 537 doc_map[id(doc)] = cand 538 sec_pruned = self.prune_documents(more_sec_docs, resolved_identity, config=config) 539 pruned_sec_docs.extend(sec_pruned) 540 elif current_offset < len(sec_docs): 541 next_batch = sec_docs[current_offset:current_offset + k_fetch] 542 if not next_batch: 543 break 544 sec_pruned = self.prune_documents(next_batch, resolved_identity, config=config) 545 pruned_sec_docs.extend(sec_pruned) 546 else: 547 break 548 current_offset += k_fetch 549 secondary_attempts += 1 550 551 permitted_candidates = [doc_map[id(doc)] for doc in pruned_sec_docs if id(doc) in doc_map] 552 553 if len(permitted_candidates) < top_k and audit_manager: 554 audit_manager.record_event(AuditEvent.create( 555 subject_id=resolved_identity.subject_id, 556 tenant_id=resolved_identity.tenant_id, 557 action=ACTION_CHUNK_RETRIEVE, 558 resource="vector_retriever", 559 decision="STARVATION", 560 diagnostic_reason=f"Post-pruning candidate count ({len(permitted_candidates)}) < target top_k ({top_k})", 561 severity=SEVERITY_WARNING, 562 metadata={"requested_k": top_k, "returned_count": len(permitted_candidates)}, 563 enforcement_point=PEP_RAG_FILTER, 564 reason_code=EVENT_RAG_RESULT_STARVATION, 565 )) 566 567 final_candidates = permitted_candidates[:top_k] 568 if get_pii_scope() == PII_SCOPE_FULL_IN_FLIGHT: 569 sanitizer = get_pii_sanitizer() 570 ctx = PiiContext.create( 571 PII_STAGE_RETRIEVAL_DOCUMENT, 572 resolved_identity.tenant_id, 573 resolved_identity.subject_id, 574 "vector_retriever", 575 {"requested_k": top_k, "returned_count": len(final_candidates)}, 576 ) 577 for doc in final_candidates: 578 if hasattr(doc, "page_content") and isinstance(doc.page_content, str): 579 doc.page_content = sanitizer.sanitize_text(doc.page_content, ctx) 580 581 return final_candidates
Encapsulates candidate wrapping in SecureDocument, oversampling fetch sizing, AST evaluation, adaptive secondary paging, and returning original candidate objects.
Args: candidates: Input raw candidates list. identity: Caller security identity. *args: Optional positional arguments (top_k, config). **kwargs: Optional keyword arguments (top_k, config).
Returns: List[Any]: Filtered list of up to top_k authorized candidate objects.
583 def invoke( 584 self, 585 input: str, 586 config: Optional[Any] = None, 587 **kwargs: Any, 588 ) -> List[SecureDocument]: 589 """Invoke the retriever synchronously capturing RunnableConfig for identity extraction and AST filtering. 590 591 Args: 592 input: Query string. 593 config: Optional RunnableConfig dictionary containing caller identity in configurable. 594 **kwargs: Additional retriever arguments. 595 596 Returns: 597 List[SecureDocument]: List of authorized SecureDocument objects. 598 """ 599 self._active_config = config 600 try: 601 return super().invoke(input, config=config, **kwargs) 602 finally: 603 self._active_config = None
Invoke the retriever synchronously capturing RunnableConfig for identity extraction and AST filtering.
Args: input: Query string. config: Optional RunnableConfig dictionary containing caller identity in configurable. **kwargs: Additional retriever arguments.
Returns: List[SecureDocument]: List of authorized SecureDocument objects.
673 def get_relevant_documents( 674 self, 675 query: str, 676 identity: Optional[SecurityIdentity] = None, 677 *args: Any, 678 **kwargs: Any, 679 ) -> List[SecureDocument]: 680 """Executes retrieval with pre-filtering, oversampling, and post-pruning. 681 682 Args: 683 query: Vector store text query string. 684 identity: Caller security identity or None. 685 *args: Optional positional config dict. 686 **kwargs: Optional keyword config dict. 687 688 Returns: 689 List[SecureDocument]: List of authorized SecureDocuments. 690 """ 691 config = kwargs.get("config", args[0] if len(args) > 0 else None) 692 filter_ast = self.build_prefilter_ast(identity, config=config) 693 694 if hasattr(self._base_retriever, "get_relevant_documents"): 695 raw_docs = self._base_retriever.get_relevant_documents(query, search_kwargs={"filter": filter_ast}) 696 elif callable(self._base_retriever): 697 raw_docs = self._base_retriever(query, filter_ast) 698 else: 699 raw_docs = [] 700 701 converted_docs: List[SecureDocument] = [] 702 for raw in raw_docs: 703 if isinstance(raw, SecureDocument): 704 converted_docs.append(raw) 705 elif hasattr(raw, "page_content") and hasattr(raw, "metadata"): 706 converted_docs.append(SecureDocument.create(page_content=raw.page_content, metadata=raw.metadata)) 707 elif isinstance(raw, dict): 708 converted_docs.append(SecureDocument.create(page_content=raw.get("page_content", ""), metadata=raw.get("metadata", {}))) 709 710 return self.prune_documents(converted_docs, identity, config=config)
Executes retrieval with pre-filtering, oversampling, and post-pruning.
Args: query: Vector store text query string. identity: Caller security identity or None. *args: Optional positional config dict. **kwargs: Optional keyword config dict.
Returns: List[SecureDocument]: List of authorized SecureDocuments.
712 def search( 713 self, 714 query: str, 715 identity: Optional[SecurityIdentity] = None, 716 *args: Any, 717 **kwargs: Any, 718 ) -> List[Any]: 719 """Executes vector similarity search with AST push-down, oversampling, and post-pruning. 720 721 Args: 722 query: Vector store text query string. 723 identity: Caller security identity or None. 724 *args: Optional positional arguments (top_k, config). 725 **kwargs: Optional keyword arguments (top_k, config). 726 727 Returns: 728 List[Any]: Filtered list of authorized candidates. 729 """ 730 top_k: int = int(args[0]) if len(args) > 0 else int(kwargs.get("top_k", DEFAULT_TOP_K_CANDIDATES)) 731 config: Optional[Dict[str, Any]] = args[1] if len(args) > 1 else kwargs.get("config") 732 733 resolved_identity = IdentityBridge.resolve_identity(identity) 734 push_down_filter = self.build_prefilter_ast(resolved_identity, config=config) 735 736 raw_k_fetch = int(top_k * self._oversampling_factor) 737 k_fetch = min(max(raw_k_fetch, top_k), DEFAULT_MAX_FETCH_CAP) 738 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 739 config["configurable"]["sec:oversampled_fetch_k"] = k_fetch 740 741 candidates: List[Any] = [] 742 if hasattr(self._base_retriever, "query"): 743 where_arg = _sanitize_chroma_filter(dict(push_down_filter)) if push_down_filter else None 744 query_kwargs: Dict[str, Any] = {"n_results": k_fetch} 745 if where_arg: 746 query_kwargs["where"] = where_arg 747 raw_res = self._base_retriever.query(query_texts=[query], **query_kwargs) 748 docs = raw_res.get("documents", [[]])[0] 749 metas = raw_res.get("metadatas", [[]])[0] 750 ids = raw_res.get("ids", [[]])[0] 751 for doc_id, doc_text, meta in zip(ids, docs, metas): 752 clean_meta = dict(meta or {}) 753 roles_val = clean_meta.get(SEC_ALLOWED_ROLES) 754 if isinstance(roles_val, str) and roles_val: 755 clean_meta[SEC_ALLOWED_ROLES] = [r.strip() for r in roles_val.split(",") if r.strip()] 756 req_roles = clean_meta.get(SEC_REQUIRED_ROLES) 757 if isinstance(req_roles, str) and req_roles: 758 clean_meta[SEC_REQUIRED_ROLES] = [r.strip() for r in req_roles.split(",") if r.strip()] 759 candidates.append(SecureDocument.create(page_content=doc_text, metadata=clean_meta)) 760 elif hasattr(self._base_retriever, "get_relevant_documents"): 761 candidates = self._base_retriever.get_relevant_documents(query, search_kwargs={"filter": push_down_filter, "k": k_fetch}) 762 elif callable(self._base_retriever): 763 candidates = self._base_retriever(query, push_down_filter, k_fetch) 764 765 return self.prune_candidates(candidates, resolved_identity, top_k, config=config)
Executes vector similarity search with AST push-down, oversampling, and post-pruning.
Args: query: Vector store text query string. identity: Caller security identity or None. *args: Optional positional arguments (top_k, config). **kwargs: Optional keyword arguments (top_k, config).
Returns: List[Any]: Filtered list of authorized candidates.
53class SecureDocument(Document): 54 """Document container representation for chunk metadata evaluation. 55 56 Attributes: 57 page_content (str): Text content of document chunk. 58 metadata (Dict[str, Any]): Security envelope metadata dictionary. 59 """ 60 61 model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) 62 score: Optional[float] = None 63 64 def __init__(self, page_content: str, metadata: Optional[Dict[str, Any]] = None, **kwargs: Any): 65 """Initialize SecureDocument. 66 67 Args: 68 page_content: Chunk page text content. 69 metadata: Metadata envelope mapping. 70 **kwargs: Additional keyword arguments. 71 """ 72 super().__init__(page_content=page_content, metadata=dict(metadata or {}), **kwargs) 73 74 @classmethod 75 def create(cls, page_content: str, metadata: Dict[str, Any], **kwargs: Any): 76 """Factory method to construct SecureDocument. 77 78 Args: 79 page_content: Chunk text string. 80 metadata: Metadata mapping. 81 **kwargs: Additional keyword arguments. 82 83 Returns: 84 SecureDocument: Constructed document instance. 85 """ 86 return cls(page_content=page_content, metadata=metadata, **kwargs)
Document container representation for chunk metadata evaluation.
Attributes: page_content (str): Text content of document chunk. metadata (Dict[str, Any]): Security envelope metadata dictionary.
74 @classmethod 75 def create(cls, page_content: str, metadata: Dict[str, Any], **kwargs: Any): 76 """Factory method to construct SecureDocument. 77 78 Args: 79 page_content: Chunk text string. 80 metadata: Metadata mapping. 81 **kwargs: Additional keyword arguments. 82 83 Returns: 84 SecureDocument: Constructed document instance. 85 """ 86 return cls(page_content=page_content, metadata=metadata, **kwargs)
Factory method to construct SecureDocument.
Args: page_content: Chunk text string. metadata: Metadata mapping. **kwargs: Additional keyword arguments.
Returns: SecureDocument: Constructed document instance.
558class DocumentSecurityTransformer(BaseDocumentTransformer): 559 """Stage 1 Pre-Processor stamping baseline document-level security envelope.""" 560 561 def __init__(self, *args: Any, **kwargs: Any): 562 """Initialize DocumentSecurityTransformer.""" 563 default_classification = kwargs.get("default_classification", args[0] if len(args) > 0 else DEFAULT_CLASSIFICATION_PARAM) 564 default_roles = kwargs.get("default_roles", args[1] if len(args) > 1 else DEFAULT_ROLES_PARAM) 565 default_tenant = kwargs.get("default_tenant", args[2] if len(args) > 2 else DEFAULT_TENANT_PARAM) 566 default_department = kwargs.get("default_department", args[3] if len(args) > 3 else DEFAULT_DEPARTMENT_PARAM) 567 rules = kwargs.get("rules", args[4] if len(args) > 4 else DEFAULT_RULES_PARAM) 568 569 if isinstance(default_classification, int): 570 self._default_clearance = default_classification 571 self._default_classification = CLASSIFICATION_PUBLIC if default_classification == 0 else CLASSIFICATION_INTERNAL 572 else: 573 self._default_classification = str(default_classification) 574 self._default_clearance = CLASSIFICATION_TO_RANK.get(self._default_classification.upper(), 0) 575 576 if "default_clearance" in kwargs: 577 self._default_clearance = int(kwargs["default_clearance"]) 578 579 self._default_roles = list(default_roles) if default_roles is not None else [] 580 self._default_tenant = default_tenant 581 self._default_department = default_department 582 self._rules = list(rules) if rules is not None else [] 583 584 @classmethod 585 def create( 586 cls, 587 *args: Any, 588 **kwargs: Any, 589 ) -> "DocumentSecurityTransformer": 590 """Factory method to construct DocumentSecurityTransformer. 591 592 Args: 593 *args: Optional positional arguments (default_classification, default_roles, default_tenant, default_department, rules). 594 **kwargs: Optional keyword arguments (default_classification, default_roles, default_tenant, default_department, rules). 595 596 Returns: 597 DocumentSecurityTransformer: Configured document transformer instance. 598 """ 599 return cls(*args, **kwargs) 600 601 @classmethod 602 def create_default(cls) -> "DocumentSecurityTransformer": 603 """Factory method constructing default DocumentSecurityTransformer. 604 605 Returns: 606 DocumentSecurityTransformer: Default transformer instance. 607 """ 608 return cls() 609 610 def transform_documents(self, documents: Sequence[Any], **kwargs: Any) -> Sequence[Any]: 611 """Apply security tags and classification rules to document metadata envelopes. 612 613 Args: 614 documents: Input document sequence. 615 616 Returns: 617 Sequence[Any]: Document sequence enriched with sec:* envelope metadata. 618 """ 619 transformed: List[Any] = [] 620 for doc in documents: 621 meta = dict(getattr(doc, "metadata", {})) 622 content = getattr(doc, "page_content", str(doc)) 623 624 if meta.get("sec:unlabelled_raw") or meta.get("sec:unlabelled") or meta.get("sec:corrupt"): 625 transformed.append(Document(page_content=content, metadata=meta)) 626 continue 627 628 classification = meta.get(SEC_CLASSIFICATION_LEVEL, self._default_classification) 629 clearance = meta.get(SEC_CLEARANCE_FLOOR, CLASSIFICATION_TO_RANK.get(str(classification).upper(), self._default_clearance)) 630 roles = list(meta.get(SEC_ALLOWED_ROLES, self._default_roles)) 631 tenant = meta.get(SEC_TENANT_ID, self._default_tenant) 632 department = meta.get(SEC_DEPARTMENT_ID, self._default_department) 633 634 for rule in self._rules: 635 res = rule(content, meta) 636 if "classification" in res: 637 classification = res["classification"] 638 clearance = CLASSIFICATION_TO_RANK.get(str(classification).upper(), clearance) 639 if "clearance" in res: 640 clearance = res["clearance"] 641 if "roles" in res: 642 roles = res["roles"] 643 if "tenant" in res: 644 tenant = res["tenant"] 645 if "department" in res: 646 department = res["department"] 647 648 doc_id = meta.get(SEC_DOCUMENT_ID, str(uuid.uuid4())) 649 meta[SEC_SCHEMA_VERSION] = DEFAULT_SCHEMA_VERSION 650 meta[SEC_DOCUMENT_ID] = doc_id 651 meta.setdefault(SEC_CHUNK_INDEX, 0) 652 meta[SEC_TENANT_ID] = tenant 653 meta[SEC_CLASSIFICATION_LEVEL] = classification 654 meta["sec:clearance_level"] = clearance 655 meta[SEC_CLEARANCE_FLOOR] = clearance 656 meta[SEC_ALLOWED_ROLES] = roles 657 meta[SEC_REQUIRED_ROLES] = roles # Dual-compatibility 658 meta[SEC_DEPARTMENT_ID] = department 659 # Dual-serialization for vector stores lacking array index 660 meta[f"{SEC_ALLOWED_ROLES}_str"] = f",{','.join(roles)}," 661 662 if isinstance(doc, SecureDocument): 663 transformed.append(SecureDocument.create(page_content=content, metadata=meta)) 664 else: 665 transformed.append(Document(page_content=content, metadata=meta)) 666 return transformed 667 668 def transform_document(self, content: str, source_metadata: Dict[str, Any]) -> SecureDocument: 669 """Transform a single document content string and metadata dict. 670 671 Args: 672 content: Document text content. 673 source_metadata: Source metadata map. 674 675 Returns: 676 SecureDocument: Enriched SecureDocument object. 677 """ 678 temp_doc = Document(page_content=content, metadata=source_metadata) 679 res = self.transform_documents([temp_doc])[0] 680 return SecureDocument.create(page_content=res.page_content, metadata=res.metadata)
Stage 1 Pre-Processor stamping baseline document-level security envelope.
561 def __init__(self, *args: Any, **kwargs: Any): 562 """Initialize DocumentSecurityTransformer.""" 563 default_classification = kwargs.get("default_classification", args[0] if len(args) > 0 else DEFAULT_CLASSIFICATION_PARAM) 564 default_roles = kwargs.get("default_roles", args[1] if len(args) > 1 else DEFAULT_ROLES_PARAM) 565 default_tenant = kwargs.get("default_tenant", args[2] if len(args) > 2 else DEFAULT_TENANT_PARAM) 566 default_department = kwargs.get("default_department", args[3] if len(args) > 3 else DEFAULT_DEPARTMENT_PARAM) 567 rules = kwargs.get("rules", args[4] if len(args) > 4 else DEFAULT_RULES_PARAM) 568 569 if isinstance(default_classification, int): 570 self._default_clearance = default_classification 571 self._default_classification = CLASSIFICATION_PUBLIC if default_classification == 0 else CLASSIFICATION_INTERNAL 572 else: 573 self._default_classification = str(default_classification) 574 self._default_clearance = CLASSIFICATION_TO_RANK.get(self._default_classification.upper(), 0) 575 576 if "default_clearance" in kwargs: 577 self._default_clearance = int(kwargs["default_clearance"]) 578 579 self._default_roles = list(default_roles) if default_roles is not None else [] 580 self._default_tenant = default_tenant 581 self._default_department = default_department 582 self._rules = list(rules) if rules is not None else []
Initialize DocumentSecurityTransformer.
584 @classmethod 585 def create( 586 cls, 587 *args: Any, 588 **kwargs: Any, 589 ) -> "DocumentSecurityTransformer": 590 """Factory method to construct DocumentSecurityTransformer. 591 592 Args: 593 *args: Optional positional arguments (default_classification, default_roles, default_tenant, default_department, rules). 594 **kwargs: Optional keyword arguments (default_classification, default_roles, default_tenant, default_department, rules). 595 596 Returns: 597 DocumentSecurityTransformer: Configured document transformer instance. 598 """ 599 return cls(*args, **kwargs)
Factory method to construct DocumentSecurityTransformer.
Args: *args: Optional positional arguments (default_classification, default_roles, default_tenant, default_department, rules). **kwargs: Optional keyword arguments (default_classification, default_roles, default_tenant, default_department, rules).
Returns: DocumentSecurityTransformer: Configured document transformer instance.
601 @classmethod 602 def create_default(cls) -> "DocumentSecurityTransformer": 603 """Factory method constructing default DocumentSecurityTransformer. 604 605 Returns: 606 DocumentSecurityTransformer: Default transformer instance. 607 """ 608 return cls()
Factory method constructing default DocumentSecurityTransformer.
Returns: DocumentSecurityTransformer: Default transformer instance.
610 def transform_documents(self, documents: Sequence[Any], **kwargs: Any) -> Sequence[Any]: 611 """Apply security tags and classification rules to document metadata envelopes. 612 613 Args: 614 documents: Input document sequence. 615 616 Returns: 617 Sequence[Any]: Document sequence enriched with sec:* envelope metadata. 618 """ 619 transformed: List[Any] = [] 620 for doc in documents: 621 meta = dict(getattr(doc, "metadata", {})) 622 content = getattr(doc, "page_content", str(doc)) 623 624 if meta.get("sec:unlabelled_raw") or meta.get("sec:unlabelled") or meta.get("sec:corrupt"): 625 transformed.append(Document(page_content=content, metadata=meta)) 626 continue 627 628 classification = meta.get(SEC_CLASSIFICATION_LEVEL, self._default_classification) 629 clearance = meta.get(SEC_CLEARANCE_FLOOR, CLASSIFICATION_TO_RANK.get(str(classification).upper(), self._default_clearance)) 630 roles = list(meta.get(SEC_ALLOWED_ROLES, self._default_roles)) 631 tenant = meta.get(SEC_TENANT_ID, self._default_tenant) 632 department = meta.get(SEC_DEPARTMENT_ID, self._default_department) 633 634 for rule in self._rules: 635 res = rule(content, meta) 636 if "classification" in res: 637 classification = res["classification"] 638 clearance = CLASSIFICATION_TO_RANK.get(str(classification).upper(), clearance) 639 if "clearance" in res: 640 clearance = res["clearance"] 641 if "roles" in res: 642 roles = res["roles"] 643 if "tenant" in res: 644 tenant = res["tenant"] 645 if "department" in res: 646 department = res["department"] 647 648 doc_id = meta.get(SEC_DOCUMENT_ID, str(uuid.uuid4())) 649 meta[SEC_SCHEMA_VERSION] = DEFAULT_SCHEMA_VERSION 650 meta[SEC_DOCUMENT_ID] = doc_id 651 meta.setdefault(SEC_CHUNK_INDEX, 0) 652 meta[SEC_TENANT_ID] = tenant 653 meta[SEC_CLASSIFICATION_LEVEL] = classification 654 meta["sec:clearance_level"] = clearance 655 meta[SEC_CLEARANCE_FLOOR] = clearance 656 meta[SEC_ALLOWED_ROLES] = roles 657 meta[SEC_REQUIRED_ROLES] = roles # Dual-compatibility 658 meta[SEC_DEPARTMENT_ID] = department 659 # Dual-serialization for vector stores lacking array index 660 meta[f"{SEC_ALLOWED_ROLES}_str"] = f",{','.join(roles)}," 661 662 if isinstance(doc, SecureDocument): 663 transformed.append(SecureDocument.create(page_content=content, metadata=meta)) 664 else: 665 transformed.append(Document(page_content=content, metadata=meta)) 666 return transformed
Apply security tags and classification rules to document metadata envelopes.
Args: documents: Input document sequence.
Returns: Sequence[Any]: Document sequence enriched with sec:* envelope metadata.
668 def transform_document(self, content: str, source_metadata: Dict[str, Any]) -> SecureDocument: 669 """Transform a single document content string and metadata dict. 670 671 Args: 672 content: Document text content. 673 source_metadata: Source metadata map. 674 675 Returns: 676 SecureDocument: Enriched SecureDocument object. 677 """ 678 temp_doc = Document(page_content=content, metadata=source_metadata) 679 res = self.transform_documents([temp_doc])[0] 680 return SecureDocument.create(page_content=res.page_content, metadata=res.metadata)
Transform a single document content string and metadata dict.
Args: content: Document text content. source_metadata: Source metadata map.
Returns: SecureDocument: Enriched SecureDocument object.
865class SecureDocumentChunkEnricher(BaseDocumentTransformer): 866 """Stage 3 Chunk Security Enricher scanning headers, XML tags, inline overrides, and executing SPI enrichers.""" 867 868 def __init__(self, *args: Any, **kwargs: Any): 869 """Initialize SecureDocumentChunkEnricher.""" 870 spi_enrichers = kwargs.get("spi_enrichers", args[0] if len(args) > 0 else DEFAULT_SPI_ENRICHERS_PARAM) 871 secret_key = kwargs.get("secret_key", args[1] if len(args) > 1 else DEFAULT_SECRET_KEY_PARAM) 872 audit_manager = kwargs.get("audit_manager", args[2] if len(args) > 2 else None) 873 self._spi_enrichers = list(spi_enrichers) if spi_enrichers is not None else [] 874 self._secret_key = secret_key 875 self._audit_manager: Optional[AuditManager] = audit_manager 876 self._heading_enricher = HeadingClassificationEnricher.create() 877 878 @classmethod 879 def create( 880 cls, 881 spi_enrichers: Optional[List[ChunkSecurityEnricherSPI]], 882 secret_key: Optional[str], 883 *args: Any, 884 **kwargs: Any, 885 ) -> "SecureDocumentChunkEnricher": 886 """Factory method to construct SecureDocumentChunkEnricher. 887 888 Args: 889 spi_enrichers: Optional list of ChunkSecurityEnricherSPI enrichers. 890 secret_key: Secret key string for envelope digest signature. 891 *args: Optional positional arguments. 892 **kwargs: Optional keyword arguments. 893 894 Returns: 895 SecureDocumentChunkEnricher: New enricher instance. 896 """ 897 return cls(spi_enrichers, secret_key, *args, **kwargs) 898 899 @classmethod 900 def create_default(cls) -> "SecureDocumentChunkEnricher": 901 """Factory method creating default SecureDocumentChunkEnricher. 902 903 Returns: 904 SecureDocumentChunkEnricher: Default enricher instance. 905 """ 906 return cls() 907 908 def _apply_monotonic_updates(self, meta: Dict[str, Any], updates: Dict[str, Any], idx: int) -> None: 909 if not updates: 910 return 911 parent_floor = int(meta.get(SEC_CLEARANCE_FLOOR, 0)) 912 de_escalation_detected = False 913 elevation_detected = False 914 915 if SEC_CLEARANCE_FLOOR in updates: 916 proposed_floor = int(updates[SEC_CLEARANCE_FLOOR]) 917 if proposed_floor < parent_floor: 918 updates[SEC_CLEARANCE_FLOOR] = parent_floor 919 de_escalation_detected = True 920 elif proposed_floor > parent_floor: 921 meta["sec:clearance_level"] = proposed_floor 922 elevation_detected = True 923 924 if SEC_CLASSIFICATION_LEVEL in updates: 925 new_rank = CLASSIFICATION_TO_RANK.get(str(updates[SEC_CLASSIFICATION_LEVEL]).upper(), 0) 926 if new_rank < parent_floor: 927 del updates[SEC_CLASSIFICATION_LEVEL] 928 de_escalation_detected = True 929 elif new_rank > parent_floor: 930 updates[SEC_CLEARANCE_FLOOR] = new_rank 931 meta["sec:clearance_level"] = new_rank 932 elevation_detected = True 933 934 meta.update(updates) 935 if de_escalation_detected: 936 meta[SEC_MONOTONIC_ELEVATION_ENFORCED] = True 937 if self._audit_manager: 938 doc_id = str(meta.get(SEC_DOCUMENT_ID, "UNKNOWN")) 939 tenant = str(meta.get(SEC_TENANT_ID, DEFAULT_TENANT_ID)) 940 self._audit_manager.record_event(AuditEvent.create( 941 subject_id="INGEST_PIPELINE", 942 tenant_id=tenant, 943 action="ingest:monotonic_elevation", 944 resource=f"{doc_id}:{idx}", 945 decision="ENFORCED", 946 diagnostic_reason=f"{EVENT_MONOTONIC_ELEVATION_ENFORCED} (CLASSIFICATION_DE_ESCALATION_REJECTED)", 947 severity=SEVERITY_WARNING, 948 metadata={"document_id": doc_id, "chunk_index": idx, "preserved_floor": parent_floor}, 949 enforcement_point=PEP_INGESTION_PIPELINE, 950 reason_code=EVENT_MONOTONIC_ELEVATION_ENFORCED, 951 )) 952 elif elevation_detected and self._audit_manager: 953 doc_id = str(meta.get(SEC_DOCUMENT_ID, "UNKNOWN")) 954 tenant = str(meta.get(SEC_TENANT_ID, DEFAULT_TENANT_ID)) 955 elevated_cls = str(meta.get(SEC_CLASSIFICATION_LEVEL, CLASSIFICATION_CONFIDENTIAL)) 956 self._audit_manager.record_event(AuditEvent.create( 957 subject_id="INGEST_PIPELINE", 958 tenant_id=tenant, 959 action="ingest:header_elevation", 960 resource=f"{doc_id}:{idx}", 961 decision="ELEVATED", 962 diagnostic_reason=f"Header Elevation: {elevated_cls} sec:clearance_floor elevated", 963 severity=SEVERITY_NOTICE, 964 metadata={"document_id": doc_id, "chunk_index": idx, "elevated_classification": elevated_cls}, 965 enforcement_point=PEP_INGESTION_PIPELINE, 966 reason_code=EVENT_INGEST_HEADER_ELEVATED, 967 )) 968 969 def enrich(self, document: Any) -> Any: 970 """Enrich a single document chunk. 971 972 Args: 973 document: Document object or SecureDocument to enrich. 974 975 Returns: 976 Any: Enriched document instance with monotonic clearance and metadata tags. 977 """ 978 return self.transform_documents([document])[0] 979 980 def transform_documents(self, documents: Sequence[Any], **kwargs: Any) -> Sequence[Any]: 981 """Enrich document chunks with monotonic clearance elevation and inline header/XML/SPI tags. 982 983 Args: 984 documents: Input chunk sequence. 985 986 Returns: 987 Sequence[Any]: Enriched chunk sequence. 988 """ 989 enriched_docs: List[Any] = [] 990 for idx, doc in enumerate(documents): 991 content = getattr(doc, "page_content", str(doc)) 992 meta = dict(getattr(doc, "metadata", {})) 993 994 meta[SEC_CHUNK_INDEX] = idx 995 996 # Built-in structural heading & XML tag enrichment with monotonic enforcement 997 heading_ctx = ChunkEnrichmentContext.create( 998 chunk_text=content, 999 parent_document_metadata=dict(meta), 1000 current_security_envelope=dict(meta), 1001 chunk_index=idx, 1002 ) 1003 heading_updates = self._heading_enricher.enrich(heading_ctx) 1004 self._apply_monotonic_updates(meta, heading_updates, idx) 1005 1006 # SPI Custom Enricher Strategy (with Monotonic Elevation Enforcement) 1007 for spi in self._spi_enrichers: 1008 if isinstance(spi, HeadingClassificationEnricher): 1009 continue 1010 ctx = ChunkEnrichmentContext.create( 1011 chunk_text=content, 1012 parent_document_metadata=dict(meta), 1013 current_security_envelope=dict(meta), 1014 chunk_index=idx, 1015 ) 1016 spi_updates = spi.enrich(ctx) 1017 self._apply_monotonic_updates(meta, spi_updates, idx) 1018 1019 # Compute optional SHA-256 HMAC digest signature 1020 if self._secret_key and not (meta.get("sec:unlabelled_raw") or meta.get("sec:unlabelled") or meta.get("sec:corrupt")): 1021 meta[SEC_ENVELOPE_HASH] = compute_envelope_hmac(meta, self._secret_key) 1022 1023 if isinstance(doc, SecureDocument): 1024 enriched_docs.append(SecureDocument.create(page_content=content, metadata=meta)) 1025 else: 1026 enriched_docs.append(Document(page_content=content, metadata=meta)) 1027 1028 return enriched_docs
Stage 3 Chunk Security Enricher scanning headers, XML tags, inline overrides, and executing SPI enrichers.
868 def __init__(self, *args: Any, **kwargs: Any): 869 """Initialize SecureDocumentChunkEnricher.""" 870 spi_enrichers = kwargs.get("spi_enrichers", args[0] if len(args) > 0 else DEFAULT_SPI_ENRICHERS_PARAM) 871 secret_key = kwargs.get("secret_key", args[1] if len(args) > 1 else DEFAULT_SECRET_KEY_PARAM) 872 audit_manager = kwargs.get("audit_manager", args[2] if len(args) > 2 else None) 873 self._spi_enrichers = list(spi_enrichers) if spi_enrichers is not None else [] 874 self._secret_key = secret_key 875 self._audit_manager: Optional[AuditManager] = audit_manager 876 self._heading_enricher = HeadingClassificationEnricher.create()
Initialize SecureDocumentChunkEnricher.
878 @classmethod 879 def create( 880 cls, 881 spi_enrichers: Optional[List[ChunkSecurityEnricherSPI]], 882 secret_key: Optional[str], 883 *args: Any, 884 **kwargs: Any, 885 ) -> "SecureDocumentChunkEnricher": 886 """Factory method to construct SecureDocumentChunkEnricher. 887 888 Args: 889 spi_enrichers: Optional list of ChunkSecurityEnricherSPI enrichers. 890 secret_key: Secret key string for envelope digest signature. 891 *args: Optional positional arguments. 892 **kwargs: Optional keyword arguments. 893 894 Returns: 895 SecureDocumentChunkEnricher: New enricher instance. 896 """ 897 return cls(spi_enrichers, secret_key, *args, **kwargs)
Factory method to construct SecureDocumentChunkEnricher.
Args: spi_enrichers: Optional list of ChunkSecurityEnricherSPI enrichers. secret_key: Secret key string for envelope digest signature. *args: Optional positional arguments. **kwargs: Optional keyword arguments.
Returns: SecureDocumentChunkEnricher: New enricher instance.
899 @classmethod 900 def create_default(cls) -> "SecureDocumentChunkEnricher": 901 """Factory method creating default SecureDocumentChunkEnricher. 902 903 Returns: 904 SecureDocumentChunkEnricher: Default enricher instance. 905 """ 906 return cls()
Factory method creating default SecureDocumentChunkEnricher.
Returns: SecureDocumentChunkEnricher: Default enricher instance.
969 def enrich(self, document: Any) -> Any: 970 """Enrich a single document chunk. 971 972 Args: 973 document: Document object or SecureDocument to enrich. 974 975 Returns: 976 Any: Enriched document instance with monotonic clearance and metadata tags. 977 """ 978 return self.transform_documents([document])[0]
Enrich a single document chunk.
Args: document: Document object or SecureDocument to enrich.
Returns: Any: Enriched document instance with monotonic clearance and metadata tags.
980 def transform_documents(self, documents: Sequence[Any], **kwargs: Any) -> Sequence[Any]: 981 """Enrich document chunks with monotonic clearance elevation and inline header/XML/SPI tags. 982 983 Args: 984 documents: Input chunk sequence. 985 986 Returns: 987 Sequence[Any]: Enriched chunk sequence. 988 """ 989 enriched_docs: List[Any] = [] 990 for idx, doc in enumerate(documents): 991 content = getattr(doc, "page_content", str(doc)) 992 meta = dict(getattr(doc, "metadata", {})) 993 994 meta[SEC_CHUNK_INDEX] = idx 995 996 # Built-in structural heading & XML tag enrichment with monotonic enforcement 997 heading_ctx = ChunkEnrichmentContext.create( 998 chunk_text=content, 999 parent_document_metadata=dict(meta), 1000 current_security_envelope=dict(meta), 1001 chunk_index=idx, 1002 ) 1003 heading_updates = self._heading_enricher.enrich(heading_ctx) 1004 self._apply_monotonic_updates(meta, heading_updates, idx) 1005 1006 # SPI Custom Enricher Strategy (with Monotonic Elevation Enforcement) 1007 for spi in self._spi_enrichers: 1008 if isinstance(spi, HeadingClassificationEnricher): 1009 continue 1010 ctx = ChunkEnrichmentContext.create( 1011 chunk_text=content, 1012 parent_document_metadata=dict(meta), 1013 current_security_envelope=dict(meta), 1014 chunk_index=idx, 1015 ) 1016 spi_updates = spi.enrich(ctx) 1017 self._apply_monotonic_updates(meta, spi_updates, idx) 1018 1019 # Compute optional SHA-256 HMAC digest signature 1020 if self._secret_key and not (meta.get("sec:unlabelled_raw") or meta.get("sec:unlabelled") or meta.get("sec:corrupt")): 1021 meta[SEC_ENVELOPE_HASH] = compute_envelope_hmac(meta, self._secret_key) 1022 1023 if isinstance(doc, SecureDocument): 1024 enriched_docs.append(SecureDocument.create(page_content=content, metadata=meta)) 1025 else: 1026 enriched_docs.append(Document(page_content=content, metadata=meta)) 1027 1028 return enriched_docs
Enrich document chunks with monotonic clearance elevation and inline header/XML/SPI tags.
Args: documents: Input chunk sequence.
Returns: Sequence[Any]: Enriched chunk sequence.
443class SecurityEnvelopeValidator(BaseDocumentTransformer): 444 """Stage 4 Fail-Closed Envelope Validator verifying presence and integrity of sec:* envelope metadata.""" 445 446 def __init__(self, *args: Any, **kwargs: Any): 447 """Initialize SecurityEnvelopeValidator.""" 448 secret_key = kwargs.get("secret_key", args[0] if len(args) > 0 else DEFAULT_SECRET_KEY_PARAM) 449 self._secret_key = secret_key 450 451 @classmethod 452 def create(cls, secret_key: Optional[str]) -> "SecurityEnvelopeValidator": 453 """Factory method creating SecurityEnvelopeValidator. 454 455 Args: 456 secret_key: Secret key string for HMAC validation. 457 458 Returns: 459 SecurityEnvelopeValidator: New validator instance. 460 """ 461 return cls(secret_key=secret_key) 462 463 @classmethod 464 def create_default(cls) -> "SecurityEnvelopeValidator": 465 """Factory method creating default SecurityEnvelopeValidator. 466 467 Returns: 468 SecurityEnvelopeValidator: Default validator instance. 469 """ 470 return cls(secret_key=DEFAULT_SECRET_KEY_PARAM) 471 472 @property 473 def secret_key(self) -> Optional[str]: 474 """Get configured HMAC secret key.""" 475 return self._secret_key 476 477 @classmethod 478 def has_mandatory_envelope(cls, metadata: Dict[str, Any]) -> bool: 479 """Validate if document metadata contains mandatory sec:* envelope keys according to Spec 1. 480 481 Mandatory keys: 482 - sec:tenant_id 483 - sec:classification_level (or sec:clearance_level) 484 - sec:allowed_roles (or sec:required_roles) 485 """ 486 if not isinstance(metadata, dict): 487 return False 488 has_tenant = bool(metadata.get(SEC_TENANT_ID)) 489 has_class = bool(metadata.get(SEC_CLASSIFICATION_LEVEL) or metadata.get("sec:clearance_level") is not None) 490 roles = metadata.get(SEC_ALLOWED_ROLES) or metadata.get(SEC_REQUIRED_ROLES) 491 has_roles = bool(roles and len(roles) > 0) 492 return bool(has_tenant and has_class and has_roles) 493 494 @classmethod 495 def validate(cls, document: Any) -> bool: 496 """Validate if document metadata contains mandatory sec:* envelope keys. 497 498 Args: 499 document: Target document or metadata dict. 500 501 Returns: 502 bool: True if required security metadata exists. 503 """ 504 meta = getattr(document, "metadata", {}) if not isinstance(document, dict) else document 505 return cls.has_mandatory_envelope(meta) 506 507 def validate_metadata(self, metadata: Dict[str, Any]) -> bool: 508 """Validate metadata dictionary key presence and HMAC signature integrity. 509 510 Args: 511 metadata: Document metadata dictionary. 512 513 Returns: 514 bool: True if valid, False if incomplete or signature mismatched. 515 """ 516 if not isinstance(metadata, dict): 517 return False 518 if metadata.get("sec:corrupt") or metadata.get("sec:unlabelled") or metadata.get("sec:unlabelled_raw"): 519 return False 520 if not self.has_mandatory_envelope(metadata): 521 return False 522 523 if self._secret_key and SEC_ENVELOPE_HASH in metadata: 524 expected_hash = compute_envelope_hmac(metadata, self._secret_key) 525 if metadata[SEC_ENVELOPE_HASH] != expected_hash: 526 logger.warning("Security envelope HMAC hash mismatch detected (possible tampering).") 527 return False 528 529 return True 530 531 def transform_documents(self, documents: Sequence[Any], **kwargs: Any) -> Sequence[Any]: 532 """Transform document collection, raising ValueError if any chunk fails validation. 533 534 Args: 535 documents: Sequence of document objects. 536 537 Returns: 538 Sequence[Any]: Validated document sequence. 539 540 Raises: 541 ValueError: If envelope validation fails for any document. 542 """ 543 validated: List[Any] = [] 544 for doc in documents: 545 meta = getattr(doc, "metadata", {}) 546 if not self.validate_metadata(meta): 547 raise ValueError("Security envelope validation failed for document chunk.") 548 validated.append(doc) 549 return validated
Stage 4 Fail-Closed Envelope Validator verifying presence and integrity of sec:* envelope metadata.
446 def __init__(self, *args: Any, **kwargs: Any): 447 """Initialize SecurityEnvelopeValidator.""" 448 secret_key = kwargs.get("secret_key", args[0] if len(args) > 0 else DEFAULT_SECRET_KEY_PARAM) 449 self._secret_key = secret_key
Initialize SecurityEnvelopeValidator.
451 @classmethod 452 def create(cls, secret_key: Optional[str]) -> "SecurityEnvelopeValidator": 453 """Factory method creating SecurityEnvelopeValidator. 454 455 Args: 456 secret_key: Secret key string for HMAC validation. 457 458 Returns: 459 SecurityEnvelopeValidator: New validator instance. 460 """ 461 return cls(secret_key=secret_key)
Factory method creating SecurityEnvelopeValidator.
Args: secret_key: Secret key string for HMAC validation.
Returns: SecurityEnvelopeValidator: New validator instance.
463 @classmethod 464 def create_default(cls) -> "SecurityEnvelopeValidator": 465 """Factory method creating default SecurityEnvelopeValidator. 466 467 Returns: 468 SecurityEnvelopeValidator: Default validator instance. 469 """ 470 return cls(secret_key=DEFAULT_SECRET_KEY_PARAM)
Factory method creating default SecurityEnvelopeValidator.
Returns: SecurityEnvelopeValidator: Default validator instance.
472 @property 473 def secret_key(self) -> Optional[str]: 474 """Get configured HMAC secret key.""" 475 return self._secret_key
Get configured HMAC secret key.
477 @classmethod 478 def has_mandatory_envelope(cls, metadata: Dict[str, Any]) -> bool: 479 """Validate if document metadata contains mandatory sec:* envelope keys according to Spec 1. 480 481 Mandatory keys: 482 - sec:tenant_id 483 - sec:classification_level (or sec:clearance_level) 484 - sec:allowed_roles (or sec:required_roles) 485 """ 486 if not isinstance(metadata, dict): 487 return False 488 has_tenant = bool(metadata.get(SEC_TENANT_ID)) 489 has_class = bool(metadata.get(SEC_CLASSIFICATION_LEVEL) or metadata.get("sec:clearance_level") is not None) 490 roles = metadata.get(SEC_ALLOWED_ROLES) or metadata.get(SEC_REQUIRED_ROLES) 491 has_roles = bool(roles and len(roles) > 0) 492 return bool(has_tenant and has_class and has_roles)
Validate if document metadata contains mandatory sec:* envelope keys according to Spec 1.
Mandatory keys:
- sec:tenant_id
- sec:classification_level (or sec:clearance_level)
- sec:allowed_roles (or sec:required_roles)
494 @classmethod 495 def validate(cls, document: Any) -> bool: 496 """Validate if document metadata contains mandatory sec:* envelope keys. 497 498 Args: 499 document: Target document or metadata dict. 500 501 Returns: 502 bool: True if required security metadata exists. 503 """ 504 meta = getattr(document, "metadata", {}) if not isinstance(document, dict) else document 505 return cls.has_mandatory_envelope(meta)
Validate if document metadata contains mandatory sec:* envelope keys.
Args: document: Target document or metadata dict.
Returns: bool: True if required security metadata exists.
507 def validate_metadata(self, metadata: Dict[str, Any]) -> bool: 508 """Validate metadata dictionary key presence and HMAC signature integrity. 509 510 Args: 511 metadata: Document metadata dictionary. 512 513 Returns: 514 bool: True if valid, False if incomplete or signature mismatched. 515 """ 516 if not isinstance(metadata, dict): 517 return False 518 if metadata.get("sec:corrupt") or metadata.get("sec:unlabelled") or metadata.get("sec:unlabelled_raw"): 519 return False 520 if not self.has_mandatory_envelope(metadata): 521 return False 522 523 if self._secret_key and SEC_ENVELOPE_HASH in metadata: 524 expected_hash = compute_envelope_hmac(metadata, self._secret_key) 525 if metadata[SEC_ENVELOPE_HASH] != expected_hash: 526 logger.warning("Security envelope HMAC hash mismatch detected (possible tampering).") 527 return False 528 529 return True
Validate metadata dictionary key presence and HMAC signature integrity.
Args: metadata: Document metadata dictionary.
Returns: bool: True if valid, False if incomplete or signature mismatched.
531 def transform_documents(self, documents: Sequence[Any], **kwargs: Any) -> Sequence[Any]: 532 """Transform document collection, raising ValueError if any chunk fails validation. 533 534 Args: 535 documents: Sequence of document objects. 536 537 Returns: 538 Sequence[Any]: Validated document sequence. 539 540 Raises: 541 ValueError: If envelope validation fails for any document. 542 """ 543 validated: List[Any] = [] 544 for doc in documents: 545 meta = getattr(doc, "metadata", {}) 546 if not self.validate_metadata(meta): 547 raise ValueError("Security envelope validation failed for document chunk.") 548 validated.append(doc) 549 return validated
Transform document collection, raising ValueError if any chunk fails validation.
Args: documents: Sequence of document objects.
Returns: Sequence[Any]: Validated document sequence.
Raises: ValueError: If envelope validation fails for any document.
1031class SecureVectorStoreIngestor: 1032 """Stage 5 Ingestion orchestrator chunking raw content, enriching with sec:* metadata, and validating modes.""" 1033 1034 def __init__(self, *args: Any, **kwargs: Any): 1035 """Initialize SecureVectorStoreIngestor. 1036 1037 Args: 1038 *args: Positional arguments (transformer, enricher, validator, ingestion_mode, text_splitter, audit_manager). 1039 **kwargs: Keyword arguments (transformer, enricher, validator, ingestion_mode, text_splitter, audit_manager). 1040 """ 1041 transformer = kwargs.get("transformer", args[0] if len(args) > 0 else None) 1042 enricher = kwargs.get("enricher", args[1] if len(args) > 1 else None) 1043 validator = kwargs.get("validator", args[2] if len(args) > 2 else None) 1044 ingestion_mode = kwargs.get("ingestion_mode", args[3] if len(args) > 3 else DEFAULT_INGESTION_MODE_PARAM) 1045 text_splitter = kwargs.get("text_splitter", args[4] if len(args) > 4 else None) 1046 audit_manager = kwargs.get("audit_manager", args[5] if len(args) > 5 else None) 1047 1048 self._transformer = transformer if transformer is not None else DocumentSecurityTransformer.create_default() 1049 self._enricher = enricher if enricher is not None else SecureDocumentChunkEnricher.create_default() 1050 self._validator = validator if validator is not None else SecurityEnvelopeValidator.create_default() 1051 self._ingestion_mode = ingestion_mode 1052 self._text_splitter = text_splitter 1053 self._audit_manager = audit_manager 1054 1055 @classmethod 1056 def create( 1057 cls, 1058 *args: Any, 1059 **kwargs: Any, 1060 ) -> "SecureVectorStoreIngestor": 1061 """Factory method constructing SecureVectorStoreIngestor. 1062 1063 Args: 1064 *args: Positional arguments (transformer, enricher, validator, ingestion_mode, text_splitter, audit_manager). 1065 **kwargs: Keyword arguments (transformer, enricher, validator, ingestion_mode, text_splitter, audit_manager). 1066 1067 Returns: 1068 SecureVectorStoreIngestor: Newly constructed ingestor orchestrator. 1069 """ 1070 return cls(*args, **kwargs) 1071 1072 @classmethod 1073 def create_default(cls) -> "SecureVectorStoreIngestor": 1074 """Factory method constructing default SecureVectorStoreIngestor. 1075 1076 Returns: 1077 SecureVectorStoreIngestor: Default ingestor instance. 1078 """ 1079 return cls() 1080 1081 @property 1082 def ingestion_mode(self) -> str: 1083 """Get configured ingestion mode.""" 1084 return self._ingestion_mode 1085 1086 def _validate_and_finalize_batch(self, enriched_docs: Sequence[Any], default_doc_id: str) -> List[SecureDocument]: 1087 final_docs: List[SecureDocument] = [] 1088 for idx, doc in enumerate(enriched_docs): 1089 meta = dict(getattr(doc, "metadata", {})) 1090 doc_id = str(meta.get(SEC_DOCUMENT_ID, default_doc_id)) 1091 meta[SEC_DOCUMENT_ID] = doc_id 1092 if meta.get(SEC_CHUNK_INDEX) is None: 1093 meta[SEC_CHUNK_INDEX] = idx 1094 if not meta.get(SEC_SCHEMA_VERSION): 1095 meta[SEC_SCHEMA_VERSION] = DEFAULT_SCHEMA_VERSION 1096 1097 if not self._validator.validate_metadata(meta): 1098 default_tenant = getattr(self._transformer, "_default_tenant", DEFAULT_TENANT_ID) 1099 tenant = str(meta.get(SEC_TENANT_ID, default_tenant)) 1100 if self._ingestion_mode == INGEST_MODE_STRICT_FAIL_CLOSED: 1101 if self._audit_manager: 1102 self._audit_manager.record_event(AuditEvent.create( 1103 subject_id="INGEST_PIPELINE", 1104 tenant_id=tenant, 1105 action="ingest:chunk", 1106 resource=f"{doc_id}:{idx}", 1107 decision="REJECTED", 1108 diagnostic_reason=( 1109 "INGEST_CHUNK_REJECTED | ATOMIC_ROLLBACK | " 1110 "UNLABELLED_CHUNK_QUARANTINED | MISSING_SECURITY_ENVELOPE: Strict fail-closed validation failure" 1111 ), 1112 severity=SEVERITY_WARNING, 1113 metadata={"document_id": doc_id, "chunk_index": idx}, 1114 enforcement_point=PEP_INGESTION_PIPELINE, 1115 reason_code=EVENT_INGEST_CHUNK_REJECTED, 1116 )) 1117 raise IngestionRejectedException.create_rejection( 1118 document_id=doc_id, 1119 chunk_index=idx, 1120 message=( 1121 f"INGEST_CHUNK_REJECTED (ATOMIC_ROLLBACK / UNLABELLED_CHUNK_QUARANTINED / MISSING_SECURITY_ENVELOPE): " 1122 f"Ingestion rejected for document {doc_id} chunk {idx} under Strict Fail-Closed mode." 1123 ), 1124 ) 1125 elif self._ingestion_mode == INGEST_MODE_EVERYTHING_CLASSIFIED: 1126 meta[SEC_TENANT_ID] = tenant 1127 meta[SEC_CLASSIFICATION_LEVEL] = CLASSIFICATION_RESTRICTED 1128 meta["sec:clearance_level"] = CLEARANCE_RANK_RESTRICTED 1129 meta[SEC_CLEARANCE_FLOOR] = CLEARANCE_RANK_RESTRICTED 1130 meta[SEC_QUARANTINED] = True 1131 if not meta.get(SEC_ALLOWED_ROLES): 1132 meta[SEC_ALLOWED_ROLES] = ["ROLE_ADMIN"] 1133 meta[SEC_REQUIRED_ROLES] = ["ROLE_ADMIN"] 1134 if getattr(self._validator, "secret_key", None): 1135 meta[SEC_ENVELOPE_HASH] = compute_envelope_hmac(meta, self._validator.secret_key) 1136 if self._audit_manager: 1137 self._audit_manager.record_event(AuditEvent.create( 1138 subject_id="INGEST_PIPELINE", 1139 tenant_id=tenant, 1140 action="ingest:chunk", 1141 resource=f"{doc_id}:{idx}", 1142 decision="QUARANTINED", 1143 diagnostic_reason="INGEST_CHUNK_QUARANTINED (sec:quarantined=true): Elevated to RESTRICTED under Everything Classified mode", 1144 severity=SEVERITY_NOTICE, 1145 metadata={"document_id": doc_id, "chunk_index": idx, SEC_QUARANTINED: True}, 1146 enforcement_point=PEP_INGESTION_PIPELINE, 1147 reason_code=EVENT_INGEST_CHUNK_QUARANTINED, 1148 )) 1149 elif self._ingestion_mode == INGEST_MODE_EVERYTHING_PUBLIC: 1150 meta[SEC_TENANT_ID] = tenant 1151 meta[SEC_CLASSIFICATION_LEVEL] = CLASSIFICATION_PUBLIC 1152 meta["sec:clearance_level"] = CLEARANCE_RANK_PUBLIC 1153 meta[SEC_CLEARANCE_FLOOR] = CLEARANCE_RANK_PUBLIC 1154 meta[SEC_ALLOWED_ROLES] = [CLASSIFICATION_PUBLIC] 1155 meta[SEC_REQUIRED_ROLES] = [CLASSIFICATION_PUBLIC] 1156 if getattr(self._validator, "secret_key", None): 1157 meta[SEC_ENVELOPE_HASH] = compute_envelope_hmac(meta, self._validator.secret_key) 1158 if self._audit_manager: 1159 self._audit_manager.record_event(AuditEvent.create( 1160 subject_id="INGEST_PIPELINE", 1161 tenant_id=tenant, 1162 action="ingest:chunk", 1163 resource=f"{doc_id}:{idx}", 1164 decision="DEFAULT_PUBLIC", 1165 diagnostic_reason="Defaulted to PUBLIC under Everything Public mode", 1166 severity=SEVERITY_NOTICE, 1167 metadata={"document_id": doc_id, "chunk_index": idx}, 1168 enforcement_point=PEP_INGESTION_PIPELINE, 1169 reason_code=EVENT_INGEST_CHUNK_DEFAULT_PUBLIC, 1170 )) 1171 1172 final_docs.append(SecureDocument.create(page_content=doc.page_content, metadata=meta)) 1173 return final_docs 1174 1175 def _split_documents(self, documents: Sequence[Any]) -> Sequence[Any]: 1176 """Stage 2 TextSplitter execution propagating metadata from parent documents to chunks.""" 1177 if self._text_splitter is None: 1178 return documents 1179 if hasattr(self._text_splitter, "transform_documents"): 1180 return self._text_splitter.transform_documents(documents) 1181 if hasattr(self._text_splitter, "split_documents"): 1182 return self._text_splitter.split_documents(documents) 1183 if hasattr(self._text_splitter, "split_text"): 1184 split_docs: List[Any] = [] 1185 for doc in documents: 1186 content = getattr(doc, "page_content", str(doc)) 1187 meta = dict(getattr(doc, "metadata", {})) 1188 chunks = self._text_splitter.split_text(content) 1189 if not chunks and content: 1190 chunks = [content] 1191 elif not chunks and content == "": 1192 chunks = [""] 1193 for chunk_idx, chunk in enumerate(chunks): 1194 chunk_meta = dict(meta) 1195 chunk_meta[SEC_CHUNK_INDEX] = chunk_idx 1196 if isinstance(doc, SecureDocument): 1197 split_docs.append(SecureDocument.create(page_content=chunk, metadata=chunk_meta)) 1198 else: 1199 split_docs.append(Document(page_content=chunk, metadata=chunk_meta)) 1200 return split_docs 1201 if callable(self._text_splitter): 1202 split_docs = [] 1203 for doc in documents: 1204 content = getattr(doc, "page_content", str(doc)) 1205 meta = dict(getattr(doc, "metadata", {})) 1206 chunks = self._text_splitter(content) 1207 if not chunks and content: 1208 chunks = [content] 1209 elif not chunks and content == "": 1210 chunks = [""] 1211 for chunk_idx, chunk in enumerate(chunks): 1212 chunk_meta = dict(meta) 1213 chunk_meta[SEC_CHUNK_INDEX] = chunk_idx 1214 if isinstance(doc, SecureDocument): 1215 split_docs.append(SecureDocument.create(page_content=chunk, metadata=chunk_meta)) 1216 else: 1217 split_docs.append(Document(page_content=chunk, metadata=chunk_meta)) 1218 return split_docs 1219 return documents 1220 1221 def ingest_documents(self, documents: Sequence[Any]) -> List[SecureDocument]: 1222 """Atomically ingest a batch of documents through the 5-stage security pipeline. 1223 1224 Document → Stage 1(Transformer) → Stage 2(TextSplitter) → Stage 3(Enricher) → Stage 4(Validator) → Stage 5(VectorStore) 1225 1226 Args: 1227 documents: Sequence of Document objects or dicts with 'page_content'/'content' and 'metadata'. 1228 1229 Returns: 1230 List[SecureDocument]: Validated SecureDocument chunks. 1231 1232 Raises: 1233 IngestionRejectedException: On any chunk validation or enrichment failure in STRICT_FAIL_CLOSED mode. 1234 """ 1235 raw_docs: List[Any] = [] 1236 for item in documents: 1237 if isinstance(item, dict): 1238 content = str(item.get("page_content") or item.get("content") or "") 1239 meta = dict(item.get("metadata") or {}) 1240 raw_docs.append(Document(page_content=content, metadata=meta)) 1241 else: 1242 raw_docs.append(item) 1243 1244 try: 1245 # Stage 1: DocumentSecurityTransformer 1246 stamped_docs = self._transformer.transform_documents(raw_docs) 1247 # Stage 2: Standard TextSplitter with metadata propagation 1248 split_docs = self._split_documents(stamped_docs) 1249 # Stage 3: SecureDocumentChunkEnricher 1250 enriched_docs = self._enricher.transform_documents(split_docs) 1251 except Exception as err: 1252 if self._ingestion_mode == INGEST_MODE_STRICT_FAIL_CLOSED: 1253 if self._audit_manager: 1254 self._audit_manager.record_event(AuditEvent.create( 1255 subject_id="INGEST_PIPELINE", 1256 tenant_id=DEFAULT_TENANT_ID, 1257 action="ingest:batch", 1258 resource="batch", 1259 decision="REJECTED", 1260 diagnostic_reason=f"INGEST_CHUNK_REJECTED | ATOMIC_ROLLBACK: {str(err)}", 1261 severity=SEVERITY_WARNING, 1262 metadata={"error": str(err)}, 1263 enforcement_point=PEP_INGESTION_PIPELINE, 1264 reason_code=EVENT_INGEST_CHUNK_REJECTED, 1265 )) 1266 raise IngestionRejectedException.create_rejection( 1267 document_id="batch", 1268 chunk_index=0, 1269 message=f"INGEST_CHUNK_REJECTED (ATOMIC_ROLLBACK): {str(err)}", 1270 ) from err 1271 raise 1272 1273 # Stage 4: SecurityEnvelopeValidator and Stage 5 return 1274 return self._validate_and_finalize_batch(enriched_docs, str(uuid.uuid4())) 1275 1276 def ingest_text( 1277 self, 1278 text: str, 1279 source_name: str, 1280 *args: Any, 1281 **kwargs: Any, 1282 ) -> List[SecureDocument]: 1283 """Ingest raw text string into secure document chunks with full envelope validation. 1284 1285 Args: 1286 text: Raw text content string. 1287 source_name: Source document name or identifier. 1288 *args: Optional positional arguments (chunk_delimiter, metadata). 1289 **kwargs: Optional keyword arguments (chunk_delimiter, metadata). 1290 1291 Returns: 1292 List[SecureDocument]: Ingested and validated SecureDocument objects. 1293 1294 Raises: 1295 IngestionRejectedException: If strict validation fails under STRICT_FAIL_CLOSED mode. 1296 """ 1297 chunk_delimiter = str(kwargs.get("chunk_delimiter", args[0] if len(args) > 0 else DEFAULT_CHUNK_DELIMITER)) 1298 metadata = kwargs.get("metadata", args[1] if len(args) > 1 else None) 1299 1300 base_meta = dict(metadata) if metadata is not None else {} 1301 base_meta["source"] = source_name 1302 doc_id = base_meta.get(SEC_DOCUMENT_ID, str(uuid.uuid4())) 1303 base_meta[SEC_DOCUMENT_ID] = doc_id 1304 1305 if self._text_splitter is not None: 1306 raw_docs = [Document(page_content=text, metadata=dict(base_meta))] 1307 return self.ingest_documents(raw_docs) 1308 1309 raw_chunks = [chunk.strip() for chunk in text.split(chunk_delimiter) if chunk.strip()] 1310 if not raw_chunks and text: 1311 raw_chunks = [text] 1312 raw_docs = [Document(page_content=chunk, metadata=dict(base_meta)) for chunk in raw_chunks] 1313 return self.ingest_documents(raw_docs)
Stage 5 Ingestion orchestrator chunking raw content, enriching with sec:* metadata, and validating modes.
1034 def __init__(self, *args: Any, **kwargs: Any): 1035 """Initialize SecureVectorStoreIngestor. 1036 1037 Args: 1038 *args: Positional arguments (transformer, enricher, validator, ingestion_mode, text_splitter, audit_manager). 1039 **kwargs: Keyword arguments (transformer, enricher, validator, ingestion_mode, text_splitter, audit_manager). 1040 """ 1041 transformer = kwargs.get("transformer", args[0] if len(args) > 0 else None) 1042 enricher = kwargs.get("enricher", args[1] if len(args) > 1 else None) 1043 validator = kwargs.get("validator", args[2] if len(args) > 2 else None) 1044 ingestion_mode = kwargs.get("ingestion_mode", args[3] if len(args) > 3 else DEFAULT_INGESTION_MODE_PARAM) 1045 text_splitter = kwargs.get("text_splitter", args[4] if len(args) > 4 else None) 1046 audit_manager = kwargs.get("audit_manager", args[5] if len(args) > 5 else None) 1047 1048 self._transformer = transformer if transformer is not None else DocumentSecurityTransformer.create_default() 1049 self._enricher = enricher if enricher is not None else SecureDocumentChunkEnricher.create_default() 1050 self._validator = validator if validator is not None else SecurityEnvelopeValidator.create_default() 1051 self._ingestion_mode = ingestion_mode 1052 self._text_splitter = text_splitter 1053 self._audit_manager = audit_manager
Initialize SecureVectorStoreIngestor.
Args: *args: Positional arguments (transformer, enricher, validator, ingestion_mode, text_splitter, audit_manager). **kwargs: Keyword arguments (transformer, enricher, validator, ingestion_mode, text_splitter, audit_manager).
1055 @classmethod 1056 def create( 1057 cls, 1058 *args: Any, 1059 **kwargs: Any, 1060 ) -> "SecureVectorStoreIngestor": 1061 """Factory method constructing SecureVectorStoreIngestor. 1062 1063 Args: 1064 *args: Positional arguments (transformer, enricher, validator, ingestion_mode, text_splitter, audit_manager). 1065 **kwargs: Keyword arguments (transformer, enricher, validator, ingestion_mode, text_splitter, audit_manager). 1066 1067 Returns: 1068 SecureVectorStoreIngestor: Newly constructed ingestor orchestrator. 1069 """ 1070 return cls(*args, **kwargs)
Factory method constructing SecureVectorStoreIngestor.
Args: *args: Positional arguments (transformer, enricher, validator, ingestion_mode, text_splitter, audit_manager). **kwargs: Keyword arguments (transformer, enricher, validator, ingestion_mode, text_splitter, audit_manager).
Returns: SecureVectorStoreIngestor: Newly constructed ingestor orchestrator.
1072 @classmethod 1073 def create_default(cls) -> "SecureVectorStoreIngestor": 1074 """Factory method constructing default SecureVectorStoreIngestor. 1075 1076 Returns: 1077 SecureVectorStoreIngestor: Default ingestor instance. 1078 """ 1079 return cls()
Factory method constructing default SecureVectorStoreIngestor.
Returns: SecureVectorStoreIngestor: Default ingestor instance.
1081 @property 1082 def ingestion_mode(self) -> str: 1083 """Get configured ingestion mode.""" 1084 return self._ingestion_mode
Get configured ingestion mode.
1221 def ingest_documents(self, documents: Sequence[Any]) -> List[SecureDocument]: 1222 """Atomically ingest a batch of documents through the 5-stage security pipeline. 1223 1224 Document → Stage 1(Transformer) → Stage 2(TextSplitter) → Stage 3(Enricher) → Stage 4(Validator) → Stage 5(VectorStore) 1225 1226 Args: 1227 documents: Sequence of Document objects or dicts with 'page_content'/'content' and 'metadata'. 1228 1229 Returns: 1230 List[SecureDocument]: Validated SecureDocument chunks. 1231 1232 Raises: 1233 IngestionRejectedException: On any chunk validation or enrichment failure in STRICT_FAIL_CLOSED mode. 1234 """ 1235 raw_docs: List[Any] = [] 1236 for item in documents: 1237 if isinstance(item, dict): 1238 content = str(item.get("page_content") or item.get("content") or "") 1239 meta = dict(item.get("metadata") or {}) 1240 raw_docs.append(Document(page_content=content, metadata=meta)) 1241 else: 1242 raw_docs.append(item) 1243 1244 try: 1245 # Stage 1: DocumentSecurityTransformer 1246 stamped_docs = self._transformer.transform_documents(raw_docs) 1247 # Stage 2: Standard TextSplitter with metadata propagation 1248 split_docs = self._split_documents(stamped_docs) 1249 # Stage 3: SecureDocumentChunkEnricher 1250 enriched_docs = self._enricher.transform_documents(split_docs) 1251 except Exception as err: 1252 if self._ingestion_mode == INGEST_MODE_STRICT_FAIL_CLOSED: 1253 if self._audit_manager: 1254 self._audit_manager.record_event(AuditEvent.create( 1255 subject_id="INGEST_PIPELINE", 1256 tenant_id=DEFAULT_TENANT_ID, 1257 action="ingest:batch", 1258 resource="batch", 1259 decision="REJECTED", 1260 diagnostic_reason=f"INGEST_CHUNK_REJECTED | ATOMIC_ROLLBACK: {str(err)}", 1261 severity=SEVERITY_WARNING, 1262 metadata={"error": str(err)}, 1263 enforcement_point=PEP_INGESTION_PIPELINE, 1264 reason_code=EVENT_INGEST_CHUNK_REJECTED, 1265 )) 1266 raise IngestionRejectedException.create_rejection( 1267 document_id="batch", 1268 chunk_index=0, 1269 message=f"INGEST_CHUNK_REJECTED (ATOMIC_ROLLBACK): {str(err)}", 1270 ) from err 1271 raise 1272 1273 # Stage 4: SecurityEnvelopeValidator and Stage 5 return 1274 return self._validate_and_finalize_batch(enriched_docs, str(uuid.uuid4()))
Atomically ingest a batch of documents through the 5-stage security pipeline.
Document → Stage 1(Transformer) → Stage 2(TextSplitter) → Stage 3(Enricher) → Stage 4(Validator) → Stage 5(VectorStore)
Args: documents: Sequence of Document objects or dicts with 'page_content'/'content' and 'metadata'.
Returns: List[SecureDocument]: Validated SecureDocument chunks.
Raises: IngestionRejectedException: On any chunk validation or enrichment failure in STRICT_FAIL_CLOSED mode.
1276 def ingest_text( 1277 self, 1278 text: str, 1279 source_name: str, 1280 *args: Any, 1281 **kwargs: Any, 1282 ) -> List[SecureDocument]: 1283 """Ingest raw text string into secure document chunks with full envelope validation. 1284 1285 Args: 1286 text: Raw text content string. 1287 source_name: Source document name or identifier. 1288 *args: Optional positional arguments (chunk_delimiter, metadata). 1289 **kwargs: Optional keyword arguments (chunk_delimiter, metadata). 1290 1291 Returns: 1292 List[SecureDocument]: Ingested and validated SecureDocument objects. 1293 1294 Raises: 1295 IngestionRejectedException: If strict validation fails under STRICT_FAIL_CLOSED mode. 1296 """ 1297 chunk_delimiter = str(kwargs.get("chunk_delimiter", args[0] if len(args) > 0 else DEFAULT_CHUNK_DELIMITER)) 1298 metadata = kwargs.get("metadata", args[1] if len(args) > 1 else None) 1299 1300 base_meta = dict(metadata) if metadata is not None else {} 1301 base_meta["source"] = source_name 1302 doc_id = base_meta.get(SEC_DOCUMENT_ID, str(uuid.uuid4())) 1303 base_meta[SEC_DOCUMENT_ID] = doc_id 1304 1305 if self._text_splitter is not None: 1306 raw_docs = [Document(page_content=text, metadata=dict(base_meta))] 1307 return self.ingest_documents(raw_docs) 1308 1309 raw_chunks = [chunk.strip() for chunk in text.split(chunk_delimiter) if chunk.strip()] 1310 if not raw_chunks and text: 1311 raw_chunks = [text] 1312 raw_docs = [Document(page_content=chunk, metadata=dict(base_meta)) for chunk in raw_chunks] 1313 return self.ingest_documents(raw_docs)
Ingest raw text string into secure document chunks with full envelope validation.
Args: text: Raw text content string. source_name: Source document name or identifier. *args: Optional positional arguments (chunk_delimiter, metadata). **kwargs: Optional keyword arguments (chunk_delimiter, metadata).
Returns: List[SecureDocument]: Ingested and validated SecureDocument objects.
Raises: IngestionRejectedException: If strict validation fails under STRICT_FAIL_CLOSED mode.
683class SecureTextSplitter(BaseDocumentTransformer): 684 """Stage 2 TextSplitter splitting documents into bounded chunks while propagating security metadata envelopes.""" 685 686 def __init__(self, *args: Any, **kwargs: Any): 687 """Initialize SecureTextSplitter. 688 689 Args: 690 *args: Optional positional arguments (chunk_size, chunk_overlap, separators). 691 **kwargs: Optional keyword arguments (chunk_size, chunk_overlap, separators). 692 """ 693 chunk_size = kwargs.get("chunk_size", args[0] if len(args) > 0 else DEFAULT_CHUNK_SIZE) 694 chunk_overlap = kwargs.get("chunk_overlap", args[1] if len(args) > 1 else DEFAULT_CHUNK_OVERLAP) 695 separators = kwargs.get("separators", args[2] if len(args) > 2 else DEFAULT_TEXT_SEPARATORS) 696 697 if int(chunk_size) <= 0: 698 raise ValueError("chunk_size must be positive") 699 if int(chunk_overlap) < 0: 700 raise ValueError("chunk_overlap must be non-negative") 701 if int(chunk_overlap) >= int(chunk_size): 702 raise ValueError("chunk_overlap must be smaller than chunk_size") 703 704 self._chunk_size = int(chunk_size) 705 self._chunk_overlap = int(chunk_overlap) 706 self._separators = list(separators) 707 708 @classmethod 709 def create(cls, *args: Any, **kwargs: Any) -> "SecureTextSplitter": 710 """Factory method constructing SecureTextSplitter. 711 712 Args: 713 *args: Optional positional arguments (chunk_size, chunk_overlap, separators). 714 **kwargs: Optional keyword arguments (chunk_size, chunk_overlap, separators). 715 716 Returns: 717 SecureTextSplitter: Newly constructed text splitter instance. 718 """ 719 return cls(*args, **kwargs) 720 721 @classmethod 722 def create_default(cls) -> "SecureTextSplitter": 723 """Factory method constructing default SecureTextSplitter.""" 724 return cls() 725 726 @property 727 def chunk_size(self) -> int: 728 """Get configured chunk size.""" 729 return self._chunk_size 730 731 @property 732 def chunk_overlap(self) -> int: 733 """Get configured chunk overlap.""" 734 return self._chunk_overlap 735 736 @property 737 def separators(self) -> List[str]: 738 """Get list of text separators.""" 739 return list(self._separators) 740 741 def _merge_splits(self, splits: List[str], separator: str) -> List[str]: 742 docs: List[str] = [] 743 current_doc: List[str] = [] 744 total = 0 745 sep_len = len(separator) if separator else 0 746 747 for d in splits: 748 _len = len(d) 749 if total + _len + (sep_len if current_doc else 0) > self._chunk_size: 750 if total > 0: 751 doc = separator.join(current_doc).strip() if separator else "".join(current_doc) 752 if doc: 753 docs.append(doc) 754 while current_doc and (total > self._chunk_overlap or (total + _len + (sep_len if current_doc else 0) > self._chunk_size and total > 0)): 755 popped = current_doc.pop(0) 756 total -= len(popped) + (sep_len if current_doc else 0) 757 if _len > self._chunk_size: 758 start = 0 759 step = self._chunk_size - self._chunk_overlap if self._chunk_size > self._chunk_overlap else self._chunk_size 760 while start < _len: 761 end = min(start + self._chunk_size, _len) 762 slice_chunk = d[start:end].strip() if separator else d[start:end] 763 if slice_chunk: 764 docs.append(slice_chunk) 765 start += step 766 current_doc = [] 767 total = 0 768 continue 769 770 current_doc.append(d) 771 total += _len + (sep_len if len(current_doc) > 1 else 0) 772 773 if current_doc: 774 doc = separator.join(current_doc).strip() if separator else "".join(current_doc) 775 if doc: 776 docs.append(doc) 777 778 return docs 779 780 def _split_text_recursive(self, text: str, separators: Sequence[str]) -> List[str]: 781 if not text: 782 return [] 783 chosen_separator: str = "" 784 new_separators: List[str] = [] 785 for i, sep in enumerate(separators): 786 if sep == "": 787 chosen_separator = "" 788 break 789 if sep in text: 790 chosen_separator = sep 791 new_separators = list(separators[i + 1:]) 792 break 793 794 if chosen_separator == "": 795 splits = list(text) 796 else: 797 splits = text.split(chosen_separator) 798 799 good_splits: List[str] = [] 800 for s in splits: 801 if not s and chosen_separator != "": 802 continue 803 if len(s) > self._chunk_size and new_separators: 804 sub_splits = self._split_text_recursive(s, new_separators) 805 good_splits.extend(sub_splits) 806 else: 807 good_splits.append(s) 808 809 return self._merge_splits(good_splits, chosen_separator) 810 811 def split_text(self, text: str) -> List[str]: 812 """Split incoming text into chunks. 813 814 Args: 815 text: Raw input text. 816 817 Returns: 818 List[str]: List of text chunks. 819 """ 820 if not text: 821 return [] 822 if len(text) <= self._chunk_size: 823 return [text.strip()] if text.strip() else [] 824 return self._split_text_recursive(text, self._separators) 825 826 def split_documents(self, documents: Sequence[Any]) -> List[Any]: 827 """Split documents into chunks while propagating metadata envelopes. 828 829 Args: 830 documents: Input sequence of document objects. 831 832 Returns: 833 List[Any]: Chunks with parent metadata propagated. 834 """ 835 chunks: List[Any] = [] 836 for doc in documents: 837 text = getattr(doc, "page_content", str(doc)) 838 metadata = dict(getattr(doc, "metadata", {})) 839 text_splits = self.split_text(text) 840 if not text_splits and text: 841 text_splits = [text] 842 elif not text_splits and text == "": 843 text_splits = [""] 844 for chunk_idx, split_chunk in enumerate(text_splits): 845 chunk_meta = dict(metadata) 846 chunk_meta[SEC_CHUNK_INDEX] = chunk_idx 847 if isinstance(doc, SecureDocument): 848 chunks.append(SecureDocument.create(page_content=split_chunk, metadata=chunk_meta)) 849 else: 850 chunks.append(Document(page_content=split_chunk, metadata=chunk_meta)) 851 return chunks 852 853 def transform_documents(self, documents: Sequence[Any], **kwargs: Any) -> Sequence[Any]: 854 """Transform documents by splitting them into chunks with metadata propagation. 855 856 Args: 857 documents: Documents to transform. 858 859 Returns: 860 Sequence[Any]: Split document chunks. 861 """ 862 return self.split_documents(documents)
Stage 2 TextSplitter splitting documents into bounded chunks while propagating security metadata envelopes.
686 def __init__(self, *args: Any, **kwargs: Any): 687 """Initialize SecureTextSplitter. 688 689 Args: 690 *args: Optional positional arguments (chunk_size, chunk_overlap, separators). 691 **kwargs: Optional keyword arguments (chunk_size, chunk_overlap, separators). 692 """ 693 chunk_size = kwargs.get("chunk_size", args[0] if len(args) > 0 else DEFAULT_CHUNK_SIZE) 694 chunk_overlap = kwargs.get("chunk_overlap", args[1] if len(args) > 1 else DEFAULT_CHUNK_OVERLAP) 695 separators = kwargs.get("separators", args[2] if len(args) > 2 else DEFAULT_TEXT_SEPARATORS) 696 697 if int(chunk_size) <= 0: 698 raise ValueError("chunk_size must be positive") 699 if int(chunk_overlap) < 0: 700 raise ValueError("chunk_overlap must be non-negative") 701 if int(chunk_overlap) >= int(chunk_size): 702 raise ValueError("chunk_overlap must be smaller than chunk_size") 703 704 self._chunk_size = int(chunk_size) 705 self._chunk_overlap = int(chunk_overlap) 706 self._separators = list(separators)
Initialize SecureTextSplitter.
Args: *args: Optional positional arguments (chunk_size, chunk_overlap, separators). **kwargs: Optional keyword arguments (chunk_size, chunk_overlap, separators).
708 @classmethod 709 def create(cls, *args: Any, **kwargs: Any) -> "SecureTextSplitter": 710 """Factory method constructing SecureTextSplitter. 711 712 Args: 713 *args: Optional positional arguments (chunk_size, chunk_overlap, separators). 714 **kwargs: Optional keyword arguments (chunk_size, chunk_overlap, separators). 715 716 Returns: 717 SecureTextSplitter: Newly constructed text splitter instance. 718 """ 719 return cls(*args, **kwargs)
Factory method constructing SecureTextSplitter.
Args: *args: Optional positional arguments (chunk_size, chunk_overlap, separators). **kwargs: Optional keyword arguments (chunk_size, chunk_overlap, separators).
Returns: SecureTextSplitter: Newly constructed text splitter instance.
721 @classmethod 722 def create_default(cls) -> "SecureTextSplitter": 723 """Factory method constructing default SecureTextSplitter.""" 724 return cls()
Factory method constructing default SecureTextSplitter.
726 @property 727 def chunk_size(self) -> int: 728 """Get configured chunk size.""" 729 return self._chunk_size
Get configured chunk size.
731 @property 732 def chunk_overlap(self) -> int: 733 """Get configured chunk overlap.""" 734 return self._chunk_overlap
Get configured chunk overlap.
736 @property 737 def separators(self) -> List[str]: 738 """Get list of text separators.""" 739 return list(self._separators)
Get list of text separators.
811 def split_text(self, text: str) -> List[str]: 812 """Split incoming text into chunks. 813 814 Args: 815 text: Raw input text. 816 817 Returns: 818 List[str]: List of text chunks. 819 """ 820 if not text: 821 return [] 822 if len(text) <= self._chunk_size: 823 return [text.strip()] if text.strip() else [] 824 return self._split_text_recursive(text, self._separators)
Split incoming text into chunks.
Args: text: Raw input text.
Returns: List[str]: List of text chunks.
826 def split_documents(self, documents: Sequence[Any]) -> List[Any]: 827 """Split documents into chunks while propagating metadata envelopes. 828 829 Args: 830 documents: Input sequence of document objects. 831 832 Returns: 833 List[Any]: Chunks with parent metadata propagated. 834 """ 835 chunks: List[Any] = [] 836 for doc in documents: 837 text = getattr(doc, "page_content", str(doc)) 838 metadata = dict(getattr(doc, "metadata", {})) 839 text_splits = self.split_text(text) 840 if not text_splits and text: 841 text_splits = [text] 842 elif not text_splits and text == "": 843 text_splits = [""] 844 for chunk_idx, split_chunk in enumerate(text_splits): 845 chunk_meta = dict(metadata) 846 chunk_meta[SEC_CHUNK_INDEX] = chunk_idx 847 if isinstance(doc, SecureDocument): 848 chunks.append(SecureDocument.create(page_content=split_chunk, metadata=chunk_meta)) 849 else: 850 chunks.append(Document(page_content=split_chunk, metadata=chunk_meta)) 851 return chunks
Split documents into chunks while propagating metadata envelopes.
Args: documents: Input sequence of document objects.
Returns: List[Any]: Chunks with parent metadata propagated.
853 def transform_documents(self, documents: Sequence[Any], **kwargs: Any) -> Sequence[Any]: 854 """Transform documents by splitting them into chunks with metadata propagation. 855 856 Args: 857 documents: Documents to transform. 858 859 Returns: 860 Sequence[Any]: Split document chunks. 861 """ 862 return self.split_documents(documents)
Transform documents by splitting them into chunks with metadata propagation.
Args: documents: Documents to transform.
Returns: Sequence[Any]: Split document chunks.
328class ChunkSecurityEnricherSPI(ABC): 329 """Extensibility interface for enterprise DLP, secret scanners, or regex classifiers.""" 330 331 @abstractmethod 332 def enrich(self, context: ChunkEnrichmentContext) -> Dict[str, Any]: 333 """Returns updated security envelope metadata dictionary. 334 335 Args: 336 context: Chunk enrichment context container. 337 338 Returns: 339 Dict[str, Any]: Security envelope metadata key-value updates. 340 """ 341 pass
Extensibility interface for enterprise DLP, secret scanners, or regex classifiers.
331 @abstractmethod 332 def enrich(self, context: ChunkEnrichmentContext) -> Dict[str, Any]: 333 """Returns updated security envelope metadata dictionary. 334 335 Args: 336 context: Chunk enrichment context container. 337 338 Returns: 339 Dict[str, Any]: Security envelope metadata key-value updates. 340 """ 341 pass
Returns updated security envelope metadata dictionary.
Args: context: Chunk enrichment context container.
Returns: Dict[str, Any]: Security envelope metadata key-value updates.
286@dataclass(frozen=True) 287class ChunkEnrichmentContext: 288 """Read-only evaluation context supplied to ChunkSecurityEnricherSPI implementations. 289 290 Attributes: 291 chunk_text (str): Content of the chunk being enriched. 292 parent_document_metadata (Dict[str, Any]): Parent document security envelope dictionary. 293 current_security_envelope (Dict[str, Any]): Active security metadata envelope. 294 chunk_index (int): Zero-based chunk index. 295 """ 296 chunk_text: str 297 parent_document_metadata: Dict[str, Any] 298 current_security_envelope: Dict[str, Any] 299 chunk_index: int 300 301 @classmethod 302 def create( 303 cls, 304 chunk_text: str, 305 parent_document_metadata: Dict[str, Any], 306 current_security_envelope: Dict[str, Any], 307 chunk_index: int, 308 ) -> "ChunkEnrichmentContext": 309 """Factory method to construct ChunkEnrichmentContext. 310 311 Args: 312 chunk_text: Chunk text content. 313 parent_document_metadata: Parent metadata map. 314 current_security_envelope: Current security envelope map. 315 chunk_index: Index of chunk. 316 317 Returns: 318 ChunkEnrichmentContext: New context instance. 319 """ 320 return cls( 321 chunk_text=chunk_text, 322 parent_document_metadata=parent_document_metadata, 323 current_security_envelope=current_security_envelope, 324 chunk_index=chunk_index, 325 )
Read-only evaluation context supplied to ChunkSecurityEnricherSPI implementations.
Attributes: chunk_text (str): Content of the chunk being enriched. parent_document_metadata (Dict[str, Any]): Parent document security envelope dictionary. current_security_envelope (Dict[str, Any]): Active security metadata envelope. chunk_index (int): Zero-based chunk index.
301 @classmethod 302 def create( 303 cls, 304 chunk_text: str, 305 parent_document_metadata: Dict[str, Any], 306 current_security_envelope: Dict[str, Any], 307 chunk_index: int, 308 ) -> "ChunkEnrichmentContext": 309 """Factory method to construct ChunkEnrichmentContext. 310 311 Args: 312 chunk_text: Chunk text content. 313 parent_document_metadata: Parent metadata map. 314 current_security_envelope: Current security envelope map. 315 chunk_index: Index of chunk. 316 317 Returns: 318 ChunkEnrichmentContext: New context instance. 319 """ 320 return cls( 321 chunk_text=chunk_text, 322 parent_document_metadata=parent_document_metadata, 323 current_security_envelope=current_security_envelope, 324 chunk_index=chunk_index, 325 )
Factory method to construct ChunkEnrichmentContext.
Args: chunk_text: Chunk text content. parent_document_metadata: Parent metadata map. current_security_envelope: Current security envelope map. chunk_index: Index of chunk.
Returns: ChunkEnrichmentContext: New context instance.
344class HeadingClassificationEnricher(ChunkSecurityEnricherSPI): 345 """Built-in ChunkSecurityEnricherSPI parsing structural Markdown headings, XML security tags, and inline role overrides.""" 346 347 @classmethod 348 def create(cls) -> "HeadingClassificationEnricher": 349 """Construct a HeadingClassificationEnricher instance. 350 351 Returns: 352 HeadingClassificationEnricher: New enricher instance. 353 """ 354 return cls() 355 356 def enrich(self, context: ChunkEnrichmentContext) -> Dict[str, Any]: 357 """Parse structural headers and inline tags from chunk text to produce security envelope updates. 358 359 Args: 360 context: ChunkEnrichmentContext providing chunk text and metadata. 361 362 Returns: 363 Dict[str, Any]: Security envelope updates parsed from headings and inline tags. 364 """ 365 updates: Dict[str, Any] = {} 366 content = context.chunk_text 367 368 header_match = HEADER_CLASSIFICATION_PATTERN.search(content) 369 if header_match: 370 header_cls = header_match.group(1).upper() 371 updates[SEC_CLASSIFICATION_LEVEL] = header_cls 372 updates[SEC_CLEARANCE_FLOOR] = CLASSIFICATION_TO_RANK.get(header_cls, 0) 373 374 xml_match = XML_CLASSIFICATION_PATTERN.search(content) 375 if xml_match: 376 xml_cls = xml_match.group(1).upper() 377 xml_rank = CLASSIFICATION_TO_RANK.get(xml_cls, 0) 378 if SEC_CLEARANCE_FLOOR not in updates or xml_rank > updates[SEC_CLEARANCE_FLOOR] or xml_rank < context.current_security_envelope.get(SEC_CLEARANCE_FLOOR, 0): 379 updates[SEC_CLASSIFICATION_LEVEL] = xml_cls 380 updates[SEC_CLEARANCE_FLOOR] = xml_rank 381 382 inline_roles_match = INLINE_ROLES_PATTERN.search(content) 383 if inline_roles_match: 384 parsed_roles = [r.strip() for r in inline_roles_match.group(1).split(",") if r.strip()] 385 if parsed_roles: 386 updates[SEC_ALLOWED_ROLES] = parsed_roles 387 updates[SEC_REQUIRED_ROLES] = parsed_roles 388 updates[f"{SEC_ALLOWED_ROLES}_str"] = f",{','.join(parsed_roles)}," 389 390 return updates
Built-in ChunkSecurityEnricherSPI parsing structural Markdown headings, XML security tags, and inline role overrides.
347 @classmethod 348 def create(cls) -> "HeadingClassificationEnricher": 349 """Construct a HeadingClassificationEnricher instance. 350 351 Returns: 352 HeadingClassificationEnricher: New enricher instance. 353 """ 354 return cls()
Construct a HeadingClassificationEnricher instance.
Returns: HeadingClassificationEnricher: New enricher instance.
356 def enrich(self, context: ChunkEnrichmentContext) -> Dict[str, Any]: 357 """Parse structural headers and inline tags from chunk text to produce security envelope updates. 358 359 Args: 360 context: ChunkEnrichmentContext providing chunk text and metadata. 361 362 Returns: 363 Dict[str, Any]: Security envelope updates parsed from headings and inline tags. 364 """ 365 updates: Dict[str, Any] = {} 366 content = context.chunk_text 367 368 header_match = HEADER_CLASSIFICATION_PATTERN.search(content) 369 if header_match: 370 header_cls = header_match.group(1).upper() 371 updates[SEC_CLASSIFICATION_LEVEL] = header_cls 372 updates[SEC_CLEARANCE_FLOOR] = CLASSIFICATION_TO_RANK.get(header_cls, 0) 373 374 xml_match = XML_CLASSIFICATION_PATTERN.search(content) 375 if xml_match: 376 xml_cls = xml_match.group(1).upper() 377 xml_rank = CLASSIFICATION_TO_RANK.get(xml_cls, 0) 378 if SEC_CLEARANCE_FLOOR not in updates or xml_rank > updates[SEC_CLEARANCE_FLOOR] or xml_rank < context.current_security_envelope.get(SEC_CLEARANCE_FLOOR, 0): 379 updates[SEC_CLASSIFICATION_LEVEL] = xml_cls 380 updates[SEC_CLEARANCE_FLOOR] = xml_rank 381 382 inline_roles_match = INLINE_ROLES_PATTERN.search(content) 383 if inline_roles_match: 384 parsed_roles = [r.strip() for r in inline_roles_match.group(1).split(",") if r.strip()] 385 if parsed_roles: 386 updates[SEC_ALLOWED_ROLES] = parsed_roles 387 updates[SEC_REQUIRED_ROLES] = parsed_roles 388 updates[f"{SEC_ALLOWED_ROLES}_str"] = f",{','.join(parsed_roles)}," 389 390 return updates
Parse structural headers and inline tags from chunk text to produce security envelope updates.
Args: context: ChunkEnrichmentContext providing chunk text and metadata.
Returns: Dict[str, Any]: Security envelope updates parsed from headings and inline tags.
393class DlpSecretDetectionEnricher(ChunkSecurityEnricherSPI): 394 """Built-in ChunkSecurityEnricherSPI scanning chunk text for credentials, secrets, and PII to elevate classification.""" 395 396 @classmethod 397 def create(cls) -> "DlpSecretDetectionEnricher": 398 """Construct a DlpSecretDetectionEnricher instance. 399 400 Returns: 401 DlpSecretDetectionEnricher: New DLP enricher instance. 402 """ 403 return cls() 404 405 def enrich(self, context: ChunkEnrichmentContext) -> Dict[str, Any]: 406 """Scan chunk text for DLP patterns and elevate to RESTRICTED if detected. 407 408 Args: 409 context: ChunkEnrichmentContext providing chunk text and metadata. 410 411 Returns: 412 Dict[str, Any]: Security envelope updates if secrets/PII are detected, or empty dict. 413 """ 414 content = context.chunk_text 415 for pattern in DLP_SECRET_PATTERNS: 416 if pattern.search(content): 417 return { 418 SEC_CLASSIFICATION_LEVEL: CLASSIFICATION_RESTRICTED, 419 SEC_CLEARANCE_FLOOR: CLEARANCE_RANK_RESTRICTED, 420 SEC_DLP_FLAGGED: True, 421 } 422 return {}
Built-in ChunkSecurityEnricherSPI scanning chunk text for credentials, secrets, and PII to elevate classification.
396 @classmethod 397 def create(cls) -> "DlpSecretDetectionEnricher": 398 """Construct a DlpSecretDetectionEnricher instance. 399 400 Returns: 401 DlpSecretDetectionEnricher: New DLP enricher instance. 402 """ 403 return cls()
Construct a DlpSecretDetectionEnricher instance.
Returns: DlpSecretDetectionEnricher: New DLP enricher instance.
405 def enrich(self, context: ChunkEnrichmentContext) -> Dict[str, Any]: 406 """Scan chunk text for DLP patterns and elevate to RESTRICTED if detected. 407 408 Args: 409 context: ChunkEnrichmentContext providing chunk text and metadata. 410 411 Returns: 412 Dict[str, Any]: Security envelope updates if secrets/PII are detected, or empty dict. 413 """ 414 content = context.chunk_text 415 for pattern in DLP_SECRET_PATTERNS: 416 if pattern.search(content): 417 return { 418 SEC_CLASSIFICATION_LEVEL: CLASSIFICATION_RESTRICTED, 419 SEC_CLEARANCE_FLOOR: CLEARANCE_RANK_RESTRICTED, 420 SEC_DLP_FLAGGED: True, 421 } 422 return {}
Scan chunk text for DLP patterns and elevate to RESTRICTED if detected.
Args: context: ChunkEnrichmentContext providing chunk text and metadata.
Returns: Dict[str, Any]: Security envelope updates if secrets/PII are detected, or empty dict.
31class SecureRunnable: 32 """Wraps agent Runnables or callables with a service boundary security evaluation gate. 33 34 Attributes: 35 runnable (Any): Wrapped LangChain Runnable or callable. 36 pdp_engine (PolicyDecisionEngine): PDP engine instance for evaluation. 37 agent_name (str): Identifier of the agent or Runnable target. 38 required_roles (List[str]): List of required role strings. 39 min_clearance (int): Minimum required clearance rank. 40 """ 41 42 def __init__( 43 self, 44 runnable: Any, 45 pdp_engine: PolicyDecisionEngine, 46 agent_name: str, 47 required_roles: Optional[List[str]], 48 min_clearance: int, 49 target_tenant: Optional[str], 50 ): 51 """Initialize SecureRunnable. 52 53 Args: 54 runnable: Target Runnable or callable. 55 pdp_engine: Policy decision engine. 56 agent_name: Agent identifier. 57 required_roles: List of required roles. 58 min_clearance: Minimum clearance floor rank. 59 target_tenant: Target tenant isolation boundary. 60 """ 61 self._runnable = runnable 62 self._pdp_engine = pdp_engine 63 self._agent_name = agent_name 64 self._required_roles = list(required_roles) if required_roles is not None else [] 65 self._min_clearance = min_clearance 66 self._target_tenant = target_tenant 67 self._violation_handler: Optional[Any] = None 68 self._violation_fallback: Optional[Any] = None 69 70 @classmethod 71 def wrap( 72 cls, 73 runnable: Any, 74 pdp_engine: PolicyDecisionEngine, 75 agent_name: str, 76 required_roles: Optional[List[str]], 77 min_clearance: int, 78 target_tenant: Optional[str], 79 ): 80 """Factory method to construct a SecureRunnable wrapper. 81 82 Args: 83 runnable: Target Runnable or callable. 84 pdp_engine: PDP decision engine. 85 agent_name: Target agent identifier. 86 required_roles: Required role strings list. 87 min_clearance: Minimum clearance level rank. 88 target_tenant: Target tenant isolation boundary. 89 90 Returns: 91 SecureRunnable: Constructed wrapper instance. 92 """ 93 return cls( 94 runnable=runnable, 95 pdp_engine=pdp_engine, 96 agent_name=agent_name, 97 required_roles=required_roles, 98 min_clearance=min_clearance, 99 target_tenant=target_tenant, 100 ) 101 102 @property 103 def agent_name(self) -> str: 104 """Get target agent identifier.""" 105 return self._agent_name 106 107 def with_violation_handler( 108 self, 109 violation_handler: Any, 110 fallback_builder: Any, 111 ) -> "SecureRunnable": 112 """Bind a declarative ToolViolationHandler and fallback builder as an LCEL error boundary. 113 114 Args: 115 violation_handler: ToolViolationHandler instance managing retry budgets and abuse tracking. 116 fallback_builder: Callable taking (input_data, violation_resolution, config) and returning fallback response. 117 118 Returns: 119 SecureRunnable: Self instance with configured violation boundary. 120 """ 121 self._violation_handler = violation_handler 122 self._violation_fallback = fallback_builder 123 return self 124 125 @staticmethod 126 def handoff( 127 target_runnable: "SecureRunnable", 128 input_data: Any, 129 config: Optional[Dict[str, Any]], 130 ) -> Any: 131 """Execute a synchronous agent-to-agent handoff preserving in-band RunnableConfig and L1 cache. 132 133 Args: 134 target_runnable: Downstream SecureRunnable receiving control. 135 input_data: Input data payload for the target runnable. 136 config: Active RunnableConfig carrying security identity and invocation state. 137 138 Returns: 139 Any: Execution result from the target runnable. 140 """ 141 if isinstance(config, dict): 142 configurable = config.setdefault("configurable", {}) 143 configurable["sec:handoff_propagated"] = True 144 configurable["sec:handoff_target"] = target_runnable.agent_name 145 return target_runnable.invoke(input_data, config) 146 147 @staticmethod 148 async def ahandoff( 149 target_runnable: "SecureRunnable", 150 input_data: Any, 151 config: Optional[Dict[str, Any]], 152 ) -> Any: 153 """Execute an asynchronous agent-to-agent handoff preserving in-band RunnableConfig and L1 cache. 154 155 Args: 156 target_runnable: Downstream SecureRunnable receiving control. 157 input_data: Input data payload for the target runnable. 158 config: Active RunnableConfig carrying security identity and invocation state. 159 160 Returns: 161 Any: Execution result from the target runnable. 162 """ 163 if isinstance(config, dict): 164 configurable = config.setdefault("configurable", {}) 165 configurable["sec:handoff_propagated"] = True 166 configurable["sec:handoff_target"] = target_runnable.agent_name 167 return await target_runnable.ainvoke(input_data, config) 168 169 def _prepare_config(self, config: Optional[Dict[str, Any]], identity: SecurityIdentity) -> Optional[Dict[str, Any]]: 170 if not isinstance(config, dict): 171 return config 172 configurable = config.setdefault("configurable", {}) 173 configurable[CONFIG_KEY_SECURITY_IDENTITY] = identity 174 if CONFIG_KEY_INVOCATION_PARAMS not in configurable: 175 inv_params = InvocationParameters.create( 176 security_identity=identity, 177 metadata=dict(config.get("metadata") or {}), 178 ) 179 configurable.update(inv_params.serialize_to_state()) 180 181 if self._pdp_engine is not None: 182 if "sec:pdp_engine" not in configurable or configurable["sec:pdp_engine"] is None: 183 configurable["sec:pdp_engine"] = self._pdp_engine 184 cel_eval = getattr(self._pdp_engine, "_cel_evaluator", None) 185 if cel_eval and getattr(cel_eval, "entity_resolver", None): 186 configurable["sec:entity_resolver"] = cel_eval.entity_resolver 187 callbacks = config.setdefault("callbacks", []) 188 if isinstance(callbacks, list) and not any(isinstance(cb, SecureCallbackHandler) for cb in callbacks): 189 callbacks.append(SecureCallbackHandler.create(pdp_engine=self._pdp_engine, security_identity=identity)) 190 return config 191 192 def _evaluate_entry_access(self, config: Optional[Dict[str, Any]]) -> SecurityIdentity: 193 """Internal helper evaluating PDP entry access and returning caller identity.""" 194 identity = IdentityBridge.extract_from_config(config) 195 if identity is None: 196 identity = IdentityBridge.resolve_identity(None) 197 198 self._prepare_config(config, identity) 199 200 tenant = self._target_tenant if self._target_tenant is not None else identity.tenant_id 201 request = PolicyEvaluationRequest.create( 202 subject=identity, 203 action=ACTION_AGENT_INVOKE, 204 resource={ 205 "target_id": self._agent_name, 206 "tenant_id": tenant, 207 "required_roles": self._required_roles, 208 "min_clearance": self._min_clearance, 209 }, 210 context={"config": config} if config else {}, 211 ) 212 213 decision = self._pdp_engine.evaluate(request) 214 audit_mgr = getattr(self._pdp_engine, "audit_manager", None) 215 if not decision.is_permitted: 216 if audit_mgr is not None: 217 audit_mgr.record_event(AuditEvent.create( 218 subject_id=identity.subject_id, 219 tenant_id=identity.tenant_id, 220 action=f"agent:invoke:{self._agent_name}", 221 resource=self._agent_name, 222 decision="DENY", 223 diagnostic_reason=decision.diagnostic_reason, 224 severity=SEVERITY_WARNING, 225 metadata={"agent_name": self._agent_name}, 226 enforcement_point=PEP_RUNNABLE_ENTRY, 227 reason_code=EVENT_AGENT_ACCESS_DENIED, 228 )) 229 raise AgentAccessDeniedException.for_agent( 230 agent_name=self._agent_name, 231 message=f"Agent invocation denied for '{self._agent_name}': {decision.diagnostic_reason}", 232 diagnostic_reason=decision.diagnostic_reason, 233 ) 234 235 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 236 config["configurable"][f"sec:entry_verified:{self._agent_name}"] = True 237 238 trace_id = identity.attributes.get("trace_id") if hasattr(identity, "attributes") else None 239 span_id = identity.attributes.get("span_id") if hasattr(identity, "attributes") else None 240 if not trace_id and isinstance(config, dict): 241 meta = config.get("metadata", {}) 242 if isinstance(meta, dict): 243 trace_id = meta.get("trace_id") 244 span_id = meta.get("span_id") 245 246 if audit_mgr is not None: 247 IdentityBridge.record_auth_success( 248 audit_manager=audit_mgr, 249 subject_id=identity.subject_id, 250 tenant_id=identity.tenant_id, 251 action=f"agent:invoke:{self._agent_name}", 252 resource=self._agent_name, 253 department=identity.department_id, 254 roles=list(identity.roles), 255 clearance_level=identity.clearance_level, 256 trace_id=trace_id, 257 span_id=span_id, 258 ) 259 return identity 260 261 def _sanitize_inbound_input(self, input_data: Any, identity: SecurityIdentity) -> Any: 262 """Sanitize inbound input payload if FULL_IN_FLIGHT PII redaction scope is enabled.""" 263 if get_pii_scope() == PII_SCOPE_FULL_IN_FLIGHT: 264 sanitizer = get_pii_sanitizer() 265 ctx = PiiContext.create(PII_STAGE_INBOUND_PROMPT, identity.tenant_id, identity.subject_id, self._agent_name, None) 266 if isinstance(input_data, str): 267 return sanitizer.sanitize_text(input_data, ctx) 268 elif isinstance(input_data, dict): 269 return sanitizer.sanitize_payload(input_data, ctx) 270 elif isinstance(input_data, list): 271 return [ 272 sanitizer.sanitize_payload(item, ctx) if isinstance(item, dict) 273 else (sanitizer.sanitize_text(item, ctx) if isinstance(item, str) else item) 274 for item in input_data 275 ] 276 return input_data 277 278 def _sanitize_outbound_output(self, result: Any, identity: SecurityIdentity) -> Any: 279 """Sanitize outbound response payload if FULL_IN_FLIGHT PII redaction scope is enabled.""" 280 if get_pii_scope() == PII_SCOPE_FULL_IN_FLIGHT: 281 sanitizer = get_pii_sanitizer() 282 ctx = PiiContext.create(PII_STAGE_OUTBOUND_RESPONSE, identity.tenant_id, identity.subject_id, self._agent_name, None) 283 if isinstance(result, str): 284 return sanitizer.sanitize_text(result, ctx) 285 elif isinstance(result, dict): 286 return sanitizer.sanitize_payload(result, ctx) 287 elif isinstance(result, list): 288 return [ 289 sanitizer.sanitize_payload(item, ctx) if isinstance(item, dict) 290 else (sanitizer.sanitize_text(item, ctx) if isinstance(item, str) else item) 291 for item in result 292 ] 293 elif hasattr(result, "content") and isinstance(result.content, str): 294 result.content = sanitizer.sanitize_text(result.content, ctx) 295 return result 296 return result 297 298 def invoke(self, input_data: Any, *args: Any, **kwargs: Any) -> Any: 299 """Synchronously invoke protected Runnable with security context binding and LCEL violation boundary. 300 301 Args: 302 input_data: Input data payload. 303 *args: Optional positional config dictionary. 304 **kwargs: Optional keyword config dictionary. 305 306 Returns: 307 Any: Invocation result from wrapped Runnable or violation fallback. 308 309 Raises: 310 AgentAccessDeniedException: If PDP denies agent entry access. 311 """ 312 config: Optional[Dict[str, Any]] = kwargs.get("config", args[0] if len(args) > 0 else None) 313 identity = self._evaluate_entry_access(config) 314 token = SecurityContextHolder.set_identity(identity) 315 input_data = self._sanitize_inbound_input(input_data, identity) 316 if isinstance(config, dict) and isinstance(config.get("configurable"), dict) and self._violation_handler is not None: 317 config["configurable"]["sec:violation_handler"] = self._violation_handler 318 config["configurable"].setdefault("sec:actions", []) 319 try: 320 if hasattr(self._runnable, "invoke"): 321 if config is not None: 322 result = self._runnable.invoke(input_data, config) 323 else: 324 result = self._runnable.invoke(input_data) 325 elif callable(self._runnable): 326 sig = inspect.signature(self._runnable) 327 if "config" in sig.parameters and config is not None: 328 result = self._runnable(input_data, config=config) 329 elif len(sig.parameters) >= 2 and config is not None: 330 result = self._runnable(input_data, config) 331 else: 332 result = self._runnable(input_data) 333 else: 334 raise ValueError("Wrapped object is neither Runnable nor callable.") 335 336 result = self._sanitize_outbound_output(result, identity) 337 338 if ( 339 self._violation_fallback is not None 340 and isinstance(config, dict) 341 and isinstance(config.get("configurable"), dict) 342 and config["configurable"].get("sec:last_violation") is not None 343 ): 344 resolution = config["configurable"].pop("sec:last_violation") 345 return self._violation_fallback(input_data, resolution, config) 346 return result 347 except AgentAccessDeniedException: 348 raise 349 except Exception as exc: 350 if self._violation_handler is not None and self._violation_fallback is not None: 351 resolution = None 352 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 353 resolution = config["configurable"].pop("sec:last_violation", None) 354 if resolution is None: 355 resolution = self._violation_handler.resolve_tool_exception(exc, config) 356 return self._violation_fallback(input_data, resolution, config) 357 raise 358 finally: 359 SecurityContextHolder.reset_identity(token) 360 361 362 async def ainvoke(self, input_data: Any, *args: Any, **kwargs: Any) -> Any: 363 """Asynchronously invoke protected Runnable with async context binding. 364 365 Args: 366 input_data: Input data payload. 367 *args: Optional positional config dictionary. 368 **kwargs: Optional keyword config dictionary. 369 370 Returns: 371 Any: Invocation result. 372 373 Raises: 374 AgentAccessDeniedException: If PDP denies agent entry access. 375 """ 376 config: Optional[Dict[str, Any]] = kwargs.get("config", args[0] if len(args) > 0 else None) 377 identity = self._evaluate_entry_access(config) 378 token = SecurityContextHolder.set_identity(identity) 379 input_data = self._sanitize_inbound_input(input_data, identity) 380 if isinstance(config, dict) and isinstance(config.get("configurable"), dict) and self._violation_handler is not None: 381 config["configurable"]["sec:violation_handler"] = self._violation_handler 382 config["configurable"].setdefault("sec:actions", []) 383 try: 384 if hasattr(self._runnable, "ainvoke"): 385 if config is not None: 386 result = await self._runnable.ainvoke(input_data, config) 387 else: 388 result = await self._runnable.ainvoke(input_data) 389 elif callable(self._runnable): 390 sig = inspect.signature(self._runnable) 391 if inspect.iscoroutinefunction(self._runnable): 392 if "config" in sig.parameters and config is not None: 393 result = await self._runnable(input_data, config=config) 394 elif len(sig.parameters) >= 2 and config is not None: 395 result = await self._runnable(input_data, config) 396 else: 397 result = await self._runnable(input_data) 398 else: 399 if "config" in sig.parameters and config is not None: 400 result = self._runnable(input_data, config=config) 401 elif len(sig.parameters) >= 2 and config is not None: 402 result = self._runnable(input_data, config) 403 else: 404 result = self._runnable(input_data) 405 else: 406 raise ValueError("Wrapped object is neither Runnable nor callable.") 407 408 result = self._sanitize_outbound_output(result, identity) 409 410 if ( 411 self._violation_fallback is not None 412 and isinstance(config, dict) 413 and isinstance(config.get("configurable"), dict) 414 and config["configurable"].get("sec:last_violation") is not None 415 ): 416 resolution = config["configurable"].pop("sec:last_violation") 417 return self._violation_fallback(input_data, resolution, config) 418 return result 419 except AgentAccessDeniedException: 420 raise 421 except Exception as exc: 422 if self._violation_handler is not None and self._violation_fallback is not None: 423 resolution = None 424 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 425 resolution = config["configurable"].pop("sec:last_violation", None) 426 if resolution is None: 427 resolution = self._violation_handler.resolve_tool_exception(exc, config) 428 return self._violation_fallback(input_data, resolution, config) 429 raise 430 finally: 431 SecurityContextHolder.reset_identity(token) 432 433 def stream(self, input_data: Any, *args: Any, **kwargs: Any) -> Iterator[Any]: 434 """Synchronously stream output chunks from protected Runnable. 435 436 Args: 437 input_data: Input data payload for stream processing. 438 *args: Optional positional arguments (config). 439 **kwargs: Optional keyword arguments (config). 440 441 Yields: 442 Any: Sanitized streamed output chunks from wrapped Runnable. 443 """ 444 config: Optional[Dict[str, Any]] = kwargs.get("config", args[0] if len(args) > 0 else None) 445 identity = self._evaluate_entry_access(config) 446 token = SecurityContextHolder.set_identity(identity) 447 input_data = self._sanitize_inbound_input(input_data, identity) 448 try: 449 if hasattr(self._runnable, "stream"): 450 if config is not None: 451 for chunk in self._runnable.stream(input_data, config): 452 yield self._sanitize_outbound_output(chunk, identity) 453 else: 454 for chunk in self._runnable.stream(input_data): 455 yield self._sanitize_outbound_output(chunk, identity) 456 else: 457 yield self.invoke(input_data, config) 458 finally: 459 SecurityContextHolder.reset_identity(token) 460 461 async def astream(self, input_data: Any, *args: Any, **kwargs: Any) -> AsyncIterator[Any]: 462 """Asynchronously stream output chunks from protected Runnable. 463 464 Args: 465 input_data: Input data payload for async stream processing. 466 *args: Optional positional arguments (config). 467 **kwargs: Optional keyword arguments (config). 468 469 Yields: 470 Any: Sanitized streamed output chunks from wrapped Runnable. 471 """ 472 config: Optional[Dict[str, Any]] = kwargs.get("config", args[0] if len(args) > 0 else None) 473 identity = self._evaluate_entry_access(config) 474 token = SecurityContextHolder.set_identity(identity) 475 input_data = self._sanitize_inbound_input(input_data, identity) 476 try: 477 if hasattr(self._runnable, "astream"): 478 if config is not None: 479 async for chunk in self._runnable.astream(input_data, config): 480 yield self._sanitize_outbound_output(chunk, identity) 481 else: 482 async for chunk in self._runnable.astream(input_data): 483 yield self._sanitize_outbound_output(chunk, identity) 484 else: 485 yield await self.ainvoke(input_data, config) 486 finally: 487 SecurityContextHolder.reset_identity(token) 488 489 490 def batch(self, inputs: List[Any], *args: Any, **kwargs: Any) -> List[Any]: 491 """Batch invoke protected Runnable across a list of input payloads. 492 493 Args: 494 inputs: List of input payloads to process. 495 *args: Optional positional arguments (config). 496 **kwargs: Optional keyword arguments (config). 497 498 Returns: 499 List[Any]: List of invocation results. 500 """ 501 config: Optional[Dict[str, Any]] = kwargs.get("config", args[0] if len(args) > 0 else None) 502 return [self.invoke(inp, config) for inp in inputs]
Wraps agent Runnables or callables with a service boundary security evaluation gate.
Attributes: runnable (Any): Wrapped LangChain Runnable or callable. pdp_engine (PolicyDecisionEngine): PDP engine instance for evaluation. agent_name (str): Identifier of the agent or Runnable target. required_roles (List[str]): List of required role strings. min_clearance (int): Minimum required clearance rank.
42 def __init__( 43 self, 44 runnable: Any, 45 pdp_engine: PolicyDecisionEngine, 46 agent_name: str, 47 required_roles: Optional[List[str]], 48 min_clearance: int, 49 target_tenant: Optional[str], 50 ): 51 """Initialize SecureRunnable. 52 53 Args: 54 runnable: Target Runnable or callable. 55 pdp_engine: Policy decision engine. 56 agent_name: Agent identifier. 57 required_roles: List of required roles. 58 min_clearance: Minimum clearance floor rank. 59 target_tenant: Target tenant isolation boundary. 60 """ 61 self._runnable = runnable 62 self._pdp_engine = pdp_engine 63 self._agent_name = agent_name 64 self._required_roles = list(required_roles) if required_roles is not None else [] 65 self._min_clearance = min_clearance 66 self._target_tenant = target_tenant 67 self._violation_handler: Optional[Any] = None 68 self._violation_fallback: Optional[Any] = None
Initialize SecureRunnable.
Args: runnable: Target Runnable or callable. pdp_engine: Policy decision engine. agent_name: Agent identifier. required_roles: List of required roles. min_clearance: Minimum clearance floor rank. target_tenant: Target tenant isolation boundary.
70 @classmethod 71 def wrap( 72 cls, 73 runnable: Any, 74 pdp_engine: PolicyDecisionEngine, 75 agent_name: str, 76 required_roles: Optional[List[str]], 77 min_clearance: int, 78 target_tenant: Optional[str], 79 ): 80 """Factory method to construct a SecureRunnable wrapper. 81 82 Args: 83 runnable: Target Runnable or callable. 84 pdp_engine: PDP decision engine. 85 agent_name: Target agent identifier. 86 required_roles: Required role strings list. 87 min_clearance: Minimum clearance level rank. 88 target_tenant: Target tenant isolation boundary. 89 90 Returns: 91 SecureRunnable: Constructed wrapper instance. 92 """ 93 return cls( 94 runnable=runnable, 95 pdp_engine=pdp_engine, 96 agent_name=agent_name, 97 required_roles=required_roles, 98 min_clearance=min_clearance, 99 target_tenant=target_tenant, 100 )
Factory method to construct a SecureRunnable wrapper.
Args: runnable: Target Runnable or callable. pdp_engine: PDP decision engine. agent_name: Target agent identifier. required_roles: Required role strings list. min_clearance: Minimum clearance level rank. target_tenant: Target tenant isolation boundary.
Returns: SecureRunnable: Constructed wrapper instance.
102 @property 103 def agent_name(self) -> str: 104 """Get target agent identifier.""" 105 return self._agent_name
Get target agent identifier.
107 def with_violation_handler( 108 self, 109 violation_handler: Any, 110 fallback_builder: Any, 111 ) -> "SecureRunnable": 112 """Bind a declarative ToolViolationHandler and fallback builder as an LCEL error boundary. 113 114 Args: 115 violation_handler: ToolViolationHandler instance managing retry budgets and abuse tracking. 116 fallback_builder: Callable taking (input_data, violation_resolution, config) and returning fallback response. 117 118 Returns: 119 SecureRunnable: Self instance with configured violation boundary. 120 """ 121 self._violation_handler = violation_handler 122 self._violation_fallback = fallback_builder 123 return self
Bind a declarative ToolViolationHandler and fallback builder as an LCEL error boundary.
Args: violation_handler: ToolViolationHandler instance managing retry budgets and abuse tracking. fallback_builder: Callable taking (input_data, violation_resolution, config) and returning fallback response.
Returns: SecureRunnable: Self instance with configured violation boundary.
125 @staticmethod 126 def handoff( 127 target_runnable: "SecureRunnable", 128 input_data: Any, 129 config: Optional[Dict[str, Any]], 130 ) -> Any: 131 """Execute a synchronous agent-to-agent handoff preserving in-band RunnableConfig and L1 cache. 132 133 Args: 134 target_runnable: Downstream SecureRunnable receiving control. 135 input_data: Input data payload for the target runnable. 136 config: Active RunnableConfig carrying security identity and invocation state. 137 138 Returns: 139 Any: Execution result from the target runnable. 140 """ 141 if isinstance(config, dict): 142 configurable = config.setdefault("configurable", {}) 143 configurable["sec:handoff_propagated"] = True 144 configurable["sec:handoff_target"] = target_runnable.agent_name 145 return target_runnable.invoke(input_data, config)
Execute a synchronous agent-to-agent handoff preserving in-band RunnableConfig and L1 cache.
Args: target_runnable: Downstream SecureRunnable receiving control. input_data: Input data payload for the target runnable. config: Active RunnableConfig carrying security identity and invocation state.
Returns: Any: Execution result from the target runnable.
147 @staticmethod 148 async def ahandoff( 149 target_runnable: "SecureRunnable", 150 input_data: Any, 151 config: Optional[Dict[str, Any]], 152 ) -> Any: 153 """Execute an asynchronous agent-to-agent handoff preserving in-band RunnableConfig and L1 cache. 154 155 Args: 156 target_runnable: Downstream SecureRunnable receiving control. 157 input_data: Input data payload for the target runnable. 158 config: Active RunnableConfig carrying security identity and invocation state. 159 160 Returns: 161 Any: Execution result from the target runnable. 162 """ 163 if isinstance(config, dict): 164 configurable = config.setdefault("configurable", {}) 165 configurable["sec:handoff_propagated"] = True 166 configurable["sec:handoff_target"] = target_runnable.agent_name 167 return await target_runnable.ainvoke(input_data, config)
Execute an asynchronous agent-to-agent handoff preserving in-band RunnableConfig and L1 cache.
Args: target_runnable: Downstream SecureRunnable receiving control. input_data: Input data payload for the target runnable. config: Active RunnableConfig carrying security identity and invocation state.
Returns: Any: Execution result from the target runnable.
298 def invoke(self, input_data: Any, *args: Any, **kwargs: Any) -> Any: 299 """Synchronously invoke protected Runnable with security context binding and LCEL violation boundary. 300 301 Args: 302 input_data: Input data payload. 303 *args: Optional positional config dictionary. 304 **kwargs: Optional keyword config dictionary. 305 306 Returns: 307 Any: Invocation result from wrapped Runnable or violation fallback. 308 309 Raises: 310 AgentAccessDeniedException: If PDP denies agent entry access. 311 """ 312 config: Optional[Dict[str, Any]] = kwargs.get("config", args[0] if len(args) > 0 else None) 313 identity = self._evaluate_entry_access(config) 314 token = SecurityContextHolder.set_identity(identity) 315 input_data = self._sanitize_inbound_input(input_data, identity) 316 if isinstance(config, dict) and isinstance(config.get("configurable"), dict) and self._violation_handler is not None: 317 config["configurable"]["sec:violation_handler"] = self._violation_handler 318 config["configurable"].setdefault("sec:actions", []) 319 try: 320 if hasattr(self._runnable, "invoke"): 321 if config is not None: 322 result = self._runnable.invoke(input_data, config) 323 else: 324 result = self._runnable.invoke(input_data) 325 elif callable(self._runnable): 326 sig = inspect.signature(self._runnable) 327 if "config" in sig.parameters and config is not None: 328 result = self._runnable(input_data, config=config) 329 elif len(sig.parameters) >= 2 and config is not None: 330 result = self._runnable(input_data, config) 331 else: 332 result = self._runnable(input_data) 333 else: 334 raise ValueError("Wrapped object is neither Runnable nor callable.") 335 336 result = self._sanitize_outbound_output(result, identity) 337 338 if ( 339 self._violation_fallback is not None 340 and isinstance(config, dict) 341 and isinstance(config.get("configurable"), dict) 342 and config["configurable"].get("sec:last_violation") is not None 343 ): 344 resolution = config["configurable"].pop("sec:last_violation") 345 return self._violation_fallback(input_data, resolution, config) 346 return result 347 except AgentAccessDeniedException: 348 raise 349 except Exception as exc: 350 if self._violation_handler is not None and self._violation_fallback is not None: 351 resolution = None 352 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 353 resolution = config["configurable"].pop("sec:last_violation", None) 354 if resolution is None: 355 resolution = self._violation_handler.resolve_tool_exception(exc, config) 356 return self._violation_fallback(input_data, resolution, config) 357 raise 358 finally: 359 SecurityContextHolder.reset_identity(token)
Synchronously invoke protected Runnable with security context binding and LCEL violation boundary.
Args: input_data: Input data payload. *args: Optional positional config dictionary. **kwargs: Optional keyword config dictionary.
Returns: Any: Invocation result from wrapped Runnable or violation fallback.
Raises: AgentAccessDeniedException: If PDP denies agent entry access.
362 async def ainvoke(self, input_data: Any, *args: Any, **kwargs: Any) -> Any: 363 """Asynchronously invoke protected Runnable with async context binding. 364 365 Args: 366 input_data: Input data payload. 367 *args: Optional positional config dictionary. 368 **kwargs: Optional keyword config dictionary. 369 370 Returns: 371 Any: Invocation result. 372 373 Raises: 374 AgentAccessDeniedException: If PDP denies agent entry access. 375 """ 376 config: Optional[Dict[str, Any]] = kwargs.get("config", args[0] if len(args) > 0 else None) 377 identity = self._evaluate_entry_access(config) 378 token = SecurityContextHolder.set_identity(identity) 379 input_data = self._sanitize_inbound_input(input_data, identity) 380 if isinstance(config, dict) and isinstance(config.get("configurable"), dict) and self._violation_handler is not None: 381 config["configurable"]["sec:violation_handler"] = self._violation_handler 382 config["configurable"].setdefault("sec:actions", []) 383 try: 384 if hasattr(self._runnable, "ainvoke"): 385 if config is not None: 386 result = await self._runnable.ainvoke(input_data, config) 387 else: 388 result = await self._runnable.ainvoke(input_data) 389 elif callable(self._runnable): 390 sig = inspect.signature(self._runnable) 391 if inspect.iscoroutinefunction(self._runnable): 392 if "config" in sig.parameters and config is not None: 393 result = await self._runnable(input_data, config=config) 394 elif len(sig.parameters) >= 2 and config is not None: 395 result = await self._runnable(input_data, config) 396 else: 397 result = await self._runnable(input_data) 398 else: 399 if "config" in sig.parameters and config is not None: 400 result = self._runnable(input_data, config=config) 401 elif len(sig.parameters) >= 2 and config is not None: 402 result = self._runnable(input_data, config) 403 else: 404 result = self._runnable(input_data) 405 else: 406 raise ValueError("Wrapped object is neither Runnable nor callable.") 407 408 result = self._sanitize_outbound_output(result, identity) 409 410 if ( 411 self._violation_fallback is not None 412 and isinstance(config, dict) 413 and isinstance(config.get("configurable"), dict) 414 and config["configurable"].get("sec:last_violation") is not None 415 ): 416 resolution = config["configurable"].pop("sec:last_violation") 417 return self._violation_fallback(input_data, resolution, config) 418 return result 419 except AgentAccessDeniedException: 420 raise 421 except Exception as exc: 422 if self._violation_handler is not None and self._violation_fallback is not None: 423 resolution = None 424 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 425 resolution = config["configurable"].pop("sec:last_violation", None) 426 if resolution is None: 427 resolution = self._violation_handler.resolve_tool_exception(exc, config) 428 return self._violation_fallback(input_data, resolution, config) 429 raise 430 finally: 431 SecurityContextHolder.reset_identity(token)
Asynchronously invoke protected Runnable with async context binding.
Args: input_data: Input data payload. *args: Optional positional config dictionary. **kwargs: Optional keyword config dictionary.
Returns: Any: Invocation result.
Raises: AgentAccessDeniedException: If PDP denies agent entry access.
433 def stream(self, input_data: Any, *args: Any, **kwargs: Any) -> Iterator[Any]: 434 """Synchronously stream output chunks from protected Runnable. 435 436 Args: 437 input_data: Input data payload for stream processing. 438 *args: Optional positional arguments (config). 439 **kwargs: Optional keyword arguments (config). 440 441 Yields: 442 Any: Sanitized streamed output chunks from wrapped Runnable. 443 """ 444 config: Optional[Dict[str, Any]] = kwargs.get("config", args[0] if len(args) > 0 else None) 445 identity = self._evaluate_entry_access(config) 446 token = SecurityContextHolder.set_identity(identity) 447 input_data = self._sanitize_inbound_input(input_data, identity) 448 try: 449 if hasattr(self._runnable, "stream"): 450 if config is not None: 451 for chunk in self._runnable.stream(input_data, config): 452 yield self._sanitize_outbound_output(chunk, identity) 453 else: 454 for chunk in self._runnable.stream(input_data): 455 yield self._sanitize_outbound_output(chunk, identity) 456 else: 457 yield self.invoke(input_data, config) 458 finally: 459 SecurityContextHolder.reset_identity(token)
Synchronously stream output chunks from protected Runnable.
Args: input_data: Input data payload for stream processing. *args: Optional positional arguments (config). **kwargs: Optional keyword arguments (config).
Yields: Any: Sanitized streamed output chunks from wrapped Runnable.
461 async def astream(self, input_data: Any, *args: Any, **kwargs: Any) -> AsyncIterator[Any]: 462 """Asynchronously stream output chunks from protected Runnable. 463 464 Args: 465 input_data: Input data payload for async stream processing. 466 *args: Optional positional arguments (config). 467 **kwargs: Optional keyword arguments (config). 468 469 Yields: 470 Any: Sanitized streamed output chunks from wrapped Runnable. 471 """ 472 config: Optional[Dict[str, Any]] = kwargs.get("config", args[0] if len(args) > 0 else None) 473 identity = self._evaluate_entry_access(config) 474 token = SecurityContextHolder.set_identity(identity) 475 input_data = self._sanitize_inbound_input(input_data, identity) 476 try: 477 if hasattr(self._runnable, "astream"): 478 if config is not None: 479 async for chunk in self._runnable.astream(input_data, config): 480 yield self._sanitize_outbound_output(chunk, identity) 481 else: 482 async for chunk in self._runnable.astream(input_data): 483 yield self._sanitize_outbound_output(chunk, identity) 484 else: 485 yield await self.ainvoke(input_data, config) 486 finally: 487 SecurityContextHolder.reset_identity(token)
Asynchronously stream output chunks from protected Runnable.
Args: input_data: Input data payload for async stream processing. *args: Optional positional arguments (config). **kwargs: Optional keyword arguments (config).
Yields: Any: Sanitized streamed output chunks from wrapped Runnable.
490 def batch(self, inputs: List[Any], *args: Any, **kwargs: Any) -> List[Any]: 491 """Batch invoke protected Runnable across a list of input payloads. 492 493 Args: 494 inputs: List of input payloads to process. 495 *args: Optional positional arguments (config). 496 **kwargs: Optional keyword arguments (config). 497 498 Returns: 499 List[Any]: List of invocation results. 500 """ 501 config: Optional[Dict[str, Any]] = kwargs.get("config", args[0] if len(args) > 0 else None) 502 return [self.invoke(inp, config) for inp in inputs]
Batch invoke protected Runnable across a list of input payloads.
Args: inputs: List of input payloads to process. *args: Optional positional arguments (config). **kwargs: Optional keyword arguments (config).
Returns: List[Any]: List of invocation results.
17class SecuredAgentState(TypedDict): 18 """LangGraph state schema incorporating in-band security envelope. 19 20 Attributes: 21 messages (Sequence[Any]): Sequence of chat message objects. 22 sec_invocation_parameters (Dict[str, Any]): Embedded security invocation parameters state dictionary. 23 """ 24 messages: Sequence[Any] 25 sec_invocation_parameters: Dict[str, Any]
LangGraph state schema incorporating in-band security envelope.
Attributes: messages (Sequence[Any]): Sequence of chat message objects. sec_invocation_parameters (Dict[str, Any]): Embedded security invocation parameters state dictionary.
28class LangGraphNodeGuard: 29 """Interceptor for LangGraph graph nodes enforcing security context propagation and authorization. 30 31 Attributes: 32 node_name (str): Identifier of graph node target. 33 required_roles (Sequence[str]): Sequence of required role strings. 34 min_clearance (int): Minimum required clearance level rank. 35 pdp_engine (PolicyDecisionEngine): PDP decision engine instance. 36 """ 37 38 def __init__( 39 self, 40 node_name: str, 41 required_roles: Sequence[str], 42 min_clearance: int, 43 pdp_engine: PolicyDecisionEngine, 44 ): 45 """Initialize LangGraphNodeGuard. 46 47 Args: 48 node_name: Target node identifier. 49 required_roles: Roles sequence. 50 min_clearance: Clearance level floor. 51 pdp_engine: PDP decision engine instance. 52 """ 53 self._node_name = node_name 54 self._required_roles = list(required_roles) 55 self._min_clearance = min_clearance 56 self._pdp_engine = pdp_engine 57 58 @classmethod 59 def create( 60 cls, 61 node_name: str, 62 required_roles: Sequence[str], 63 min_clearance: int, 64 pdp_engine: PolicyDecisionEngine, 65 ): 66 """Factory method constructing a LangGraphNodeGuard. 67 68 Args: 69 node_name: Target graph node name. 70 required_roles: Required role strings sequence. 71 min_clearance: Clearance floor rank. 72 pdp_engine: PDP decision engine. 73 74 Returns: 75 LangGraphNodeGuard: New guard instance. 76 """ 77 return cls( 78 node_name=node_name, 79 required_roles=required_roles, 80 min_clearance=min_clearance, 81 pdp_engine=pdp_engine, 82 ) 83 84 @property 85 def node_name(self) -> str: 86 """Get the guarded node identifier string.""" 87 return self._node_name 88 89 @property 90 def required_roles(self) -> Sequence[str]: 91 """Get copy of required role identifiers.""" 92 return list(self._required_roles) 93 94 @property 95 def min_clearance(self) -> int: 96 """Get minimum clearance level rank.""" 97 return self._min_clearance 98 99 @property 100 def pdp_engine(self) -> PolicyDecisionEngine: 101 """Get the configured PolicyDecisionEngine instance.""" 102 return self._pdp_engine 103 104 def wrap_node(self, node_fn: Callable[[Dict[str, Any]], Dict[str, Any]]) -> Callable[[Dict[str, Any]], Dict[str, Any]]: 105 """Wraps a LangGraph node function with security evaluation. 106 107 Args: 108 node_fn: Target graph node callable accepting state dictionary and returning updated state. 109 110 Returns: 111 Callable[[Dict[str, Any]], Dict[str, Any]]: Guarded node function. 112 """ 113 114 def guarded_node(state: Dict[str, Any]) -> Dict[str, Any]: 115 inv_params = InvocationParameters.deserialize_from_state(state) 116 identity = inv_params.security_identity 117 118 request = PolicyEvaluationRequest.create( 119 subject=identity, 120 action=ACTION_AGENT_INVOKE, 121 resource={ 122 "target_id": self._node_name, 123 "tenant_id": identity.tenant_id, 124 "required_roles": self._required_roles, 125 "min_clearance": self._min_clearance, 126 }, 127 context={"node_name": self._node_name}, 128 ) 129 130 decision = self._pdp_engine.evaluate(request) 131 if not decision.is_permitted: 132 raise SecurityAuthorizationError.create( 133 message=f"LangGraph Node '{self._node_name}' authorization failed: {decision.diagnostic_reason}", 134 diagnostic_reason=decision.diagnostic_reason, 135 obligations=decision.obligations, 136 ) 137 138 # Ensure security envelope persists in returned state 139 output_state = node_fn(state) 140 if isinstance(output_state, dict) and SECURITY_INVOCATION_PARAMS_KEY not in output_state: 141 output_state[SECURITY_INVOCATION_PARAMS_KEY] = state.get( 142 SECURITY_INVOCATION_PARAMS_KEY, 143 inv_params.serialize_to_state()[SECURITY_INVOCATION_PARAMS_KEY], 144 ) 145 return output_state 146 147 return guarded_node
Interceptor for LangGraph graph nodes enforcing security context propagation and authorization.
Attributes: node_name (str): Identifier of graph node target. required_roles (Sequence[str]): Sequence of required role strings. min_clearance (int): Minimum required clearance level rank. pdp_engine (PolicyDecisionEngine): PDP decision engine instance.
38 def __init__( 39 self, 40 node_name: str, 41 required_roles: Sequence[str], 42 min_clearance: int, 43 pdp_engine: PolicyDecisionEngine, 44 ): 45 """Initialize LangGraphNodeGuard. 46 47 Args: 48 node_name: Target node identifier. 49 required_roles: Roles sequence. 50 min_clearance: Clearance level floor. 51 pdp_engine: PDP decision engine instance. 52 """ 53 self._node_name = node_name 54 self._required_roles = list(required_roles) 55 self._min_clearance = min_clearance 56 self._pdp_engine = pdp_engine
Initialize LangGraphNodeGuard.
Args: node_name: Target node identifier. required_roles: Roles sequence. min_clearance: Clearance level floor. pdp_engine: PDP decision engine instance.
58 @classmethod 59 def create( 60 cls, 61 node_name: str, 62 required_roles: Sequence[str], 63 min_clearance: int, 64 pdp_engine: PolicyDecisionEngine, 65 ): 66 """Factory method constructing a LangGraphNodeGuard. 67 68 Args: 69 node_name: Target graph node name. 70 required_roles: Required role strings sequence. 71 min_clearance: Clearance floor rank. 72 pdp_engine: PDP decision engine. 73 74 Returns: 75 LangGraphNodeGuard: New guard instance. 76 """ 77 return cls( 78 node_name=node_name, 79 required_roles=required_roles, 80 min_clearance=min_clearance, 81 pdp_engine=pdp_engine, 82 )
Factory method constructing a LangGraphNodeGuard.
Args: node_name: Target graph node name. required_roles: Required role strings sequence. min_clearance: Clearance floor rank. pdp_engine: PDP decision engine.
Returns: LangGraphNodeGuard: New guard instance.
84 @property 85 def node_name(self) -> str: 86 """Get the guarded node identifier string.""" 87 return self._node_name
Get the guarded node identifier string.
89 @property 90 def required_roles(self) -> Sequence[str]: 91 """Get copy of required role identifiers.""" 92 return list(self._required_roles)
Get copy of required role identifiers.
94 @property 95 def min_clearance(self) -> int: 96 """Get minimum clearance level rank.""" 97 return self._min_clearance
Get minimum clearance level rank.
99 @property 100 def pdp_engine(self) -> PolicyDecisionEngine: 101 """Get the configured PolicyDecisionEngine instance.""" 102 return self._pdp_engine
Get the configured PolicyDecisionEngine instance.
104 def wrap_node(self, node_fn: Callable[[Dict[str, Any]], Dict[str, Any]]) -> Callable[[Dict[str, Any]], Dict[str, Any]]: 105 """Wraps a LangGraph node function with security evaluation. 106 107 Args: 108 node_fn: Target graph node callable accepting state dictionary and returning updated state. 109 110 Returns: 111 Callable[[Dict[str, Any]], Dict[str, Any]]: Guarded node function. 112 """ 113 114 def guarded_node(state: Dict[str, Any]) -> Dict[str, Any]: 115 inv_params = InvocationParameters.deserialize_from_state(state) 116 identity = inv_params.security_identity 117 118 request = PolicyEvaluationRequest.create( 119 subject=identity, 120 action=ACTION_AGENT_INVOKE, 121 resource={ 122 "target_id": self._node_name, 123 "tenant_id": identity.tenant_id, 124 "required_roles": self._required_roles, 125 "min_clearance": self._min_clearance, 126 }, 127 context={"node_name": self._node_name}, 128 ) 129 130 decision = self._pdp_engine.evaluate(request) 131 if not decision.is_permitted: 132 raise SecurityAuthorizationError.create( 133 message=f"LangGraph Node '{self._node_name}' authorization failed: {decision.diagnostic_reason}", 134 diagnostic_reason=decision.diagnostic_reason, 135 obligations=decision.obligations, 136 ) 137 138 # Ensure security envelope persists in returned state 139 output_state = node_fn(state) 140 if isinstance(output_state, dict) and SECURITY_INVOCATION_PARAMS_KEY not in output_state: 141 output_state[SECURITY_INVOCATION_PARAMS_KEY] = state.get( 142 SECURITY_INVOCATION_PARAMS_KEY, 143 inv_params.serialize_to_state()[SECURITY_INVOCATION_PARAMS_KEY], 144 ) 145 return output_state 146 147 return guarded_node
Wraps a LangGraph node function with security evaluation.
Args: node_fn: Target graph node callable accepting state dictionary and returning updated state.
Returns: Callable[[Dict[str, Any]], Dict[str, Any]]: Guarded node function.
337class ToolViolationHandler: 338 """PEP violation handler integrating retry tracking, abuse throttling, and fallback generation.""" 339 340 def __init__( 341 self, 342 pdp_engine: PolicyDecisionEngine, 343 retry_tracker: RetryBudgetTracker, 344 abuse_tracker: AbuseThrottleTracker, 345 ): 346 """Initialize ToolViolationHandler. 347 348 Args: 349 pdp_engine: PDP decision engine for evaluating and logging violations. 350 retry_tracker: RetryBudgetTracker instance. 351 abuse_tracker: AbuseThrottleTracker instance. 352 """ 353 self._pdp_engine = pdp_engine 354 self._retry_tracker = retry_tracker 355 self._abuse_tracker = abuse_tracker 356 if hasattr(self._pdp_engine, "register_invalidation_callback"): 357 self._pdp_engine.register_invalidation_callback(self.reset_abuse) 358 359 def reset_abuse(self, *args: Any, **kwargs: Any) -> None: 360 """Reset abuse tracking for a specific subject or all subjects. 361 362 Args: 363 *args: Optional positional subject_id. 364 **kwargs: Optional keyword subject_id. 365 """ 366 self._abuse_tracker.reset(*args, **kwargs) 367 368 @classmethod 369 def create( 370 cls, 371 pdp_engine: PolicyDecisionEngine, 372 retry_tracker: RetryBudgetTracker, 373 abuse_tracker: AbuseThrottleTracker, 374 ) -> "ToolViolationHandler": 375 """Factory method constructing a ToolViolationHandler. 376 377 Args: 378 pdp_engine: PDP decision engine. 379 retry_tracker: Tracker for model feedback retries. 380 abuse_tracker: Tracker for subject-level abuse denial bursts. 381 382 Returns: 383 ToolViolationHandler: New handler instance. 384 """ 385 return cls( 386 pdp_engine=pdp_engine, 387 retry_tracker=retry_tracker, 388 abuse_tracker=abuse_tracker, 389 ) 390 391 @classmethod 392 def default_handler(cls, pdp_engine: PolicyDecisionEngine) -> "ToolViolationHandler": 393 """Factory method constructing default handler with standard limits. 394 395 Args: 396 pdp_engine: PDP decision engine instance. 397 398 Returns: 399 ToolViolationHandler: Handler initialized with standard 2-retry budget and 60s sliding abuse window. 400 """ 401 retry_tracker = RetryBudgetTracker.create(max_feedback_violations=DEFAULT_MAX_FEEDBACK_VIOLATIONS) 402 abuse_tracker = AbuseThrottleTracker.create( 403 window_seconds=DEFAULT_ABUSE_WINDOW_SECONDS, 404 max_denials=DEFAULT_ABUSE_MAX_DENIALS, 405 ) 406 return cls.create( 407 pdp_engine=pdp_engine, 408 retry_tracker=retry_tracker, 409 abuse_tracker=abuse_tracker, 410 ) 411 412 @property 413 def pdp_engine(self) -> PolicyDecisionEngine: 414 """Get the bound PolicyDecisionEngine.""" 415 return self._pdp_engine 416 417 @property 418 def retry_tracker(self) -> RetryBudgetTracker: 419 """Get the bound RetryBudgetTracker.""" 420 return self._retry_tracker 421 422 @property 423 def abuse_tracker(self) -> AbuseThrottleTracker: 424 """Get the bound AbuseThrottleTracker.""" 425 return self._abuse_tracker 426 427 def record_tool_violation( 428 self, 429 tool_name: str, 430 diagnostic_reason: str, 431 is_mutative: bool, 432 config: Optional[Dict[str, Any]], 433 caller_identity: Optional[SecurityIdentity], 434 ) -> ViolationResolution: 435 """Record a tool policy violation, tracks abuse/retries, and returns structured resolution. 436 437 Args: 438 tool_name: Name of tool whose execution was denied. 439 diagnostic_reason: Diagnostic reason string from PDP decision. 440 is_mutative: True if tool modifies system state, triggering immediate hard abort. 441 config: Optional RunnableConfig dictionary for tracking state and retry counts. 442 caller_identity: Optional SecurityIdentity envelope of calling subject. 443 444 Returns: 445 ViolationResolution: Structured resolution detailing telemetry, status, and abort flags. 446 """ 447 is_budget_exceeded = self._retry_tracker.is_budget_exceeded(config) 448 is_abusing = False 449 450 if caller_identity is not None and self._abuse_tracker.record_denial(caller_identity.subject_id, time.time()): 451 is_abusing = True 452 audit_mgr = getattr(self._pdp_engine, "audit_manager", None) 453 if audit_mgr is not None: 454 audit_mgr.record_event( 455 AuditEvent.create( 456 subject_id=caller_identity.subject_id, 457 tenant_id=caller_identity.tenant_id, 458 action=ACTION_TOOL_EXECUTE, 459 resource=tool_name, 460 decision="HARD_ABORT", 461 diagnostic_reason="Abuse throttle threshold exceeded: Brute-force scanning detected across window", 462 severity=SEVERITY_CRITICAL_ALERT, 463 metadata={"tool_name": tool_name}, 464 enforcement_point=PEP_TOOL_INTERCEPTOR, 465 reason_code=EVENT_POLICY_BRUTE_FORCE_SCANNING, 466 ) 467 ) 468 469 if is_budget_exceeded: 470 audit_mgr = getattr(self._pdp_engine, "audit_manager", None) 471 if audit_mgr is not None and caller_identity is not None: 472 audit_mgr.record_event( 473 AuditEvent.create( 474 subject_id=caller_identity.subject_id, 475 tenant_id=caller_identity.tenant_id, 476 action=ACTION_TOOL_EXECUTE, 477 resource=tool_name, 478 decision="HARD_ABORT", 479 diagnostic_reason="Retry budget exceeded: Policy brute force probe detected", 480 severity=SEVERITY_CRITICAL_ALERT, 481 metadata={"tool_name": tool_name}, 482 enforcement_point=PEP_TOOL_INTERCEPTOR, 483 reason_code=EVENT_POLICY_BRUTE_FORCE_DETECTED, 484 ) 485 ) 486 root_cause = "Policy Abort: POLICY_BRUTE_FORCE_SCANNING" 487 user_msg = f"Execution halted: retry budget exceeded (POLICY_BRUTE_FORCE_DETECTED on {tool_name})" 488 action_entry = f"Blocked execution of tool '{tool_name}' (retry budget exceeded)" 489 should_abort = True 490 status = STATUS_BLOCKED_BY_POLICY 491 policy_decision = POLICY_STATUS_BLOCKED 492 execution_action = EXECUTION_ACTION_ABORTED 493 elif is_mutative: 494 root_cause = f"Policy violation: Access denied for tool '{tool_name}' ({diagnostic_reason})" 495 user_msg = f"Policy violation: Access denied for tool '{tool_name}' ({diagnostic_reason})" 496 action_entry = f"Blocked execution of tool '{tool_name}' ({diagnostic_reason})" 497 should_abort = True 498 status = STATUS_BLOCKED_BY_POLICY 499 policy_decision = POLICY_STATUS_BLOCKED 500 execution_action = EXECUTION_ACTION_ABORTED 501 else: 502 root_cause = f"Policy restriction: Informational tool '{tool_name}' access denied ({diagnostic_reason})" 503 user_msg = f"Policy restriction: Informational tool '{tool_name}' access denied ({diagnostic_reason})" 504 action_entry = f"Blocked execution of tool '{tool_name}' ({diagnostic_reason})" 505 should_abort = is_abusing 506 status = STATUS_BLOCKED_BY_POLICY if is_abusing else "ACCESS_DENIED" 507 policy_decision = POLICY_STATUS_BLOCKED if is_abusing else POLICY_STATUS_FEEDBACK 508 execution_action = EXECUTION_ACTION_ABORTED if is_abusing else EXECUTION_ACTION_FEEDBACK 509 510 resolution = ViolationResolution.create( 511 status=status, 512 action_entry=action_entry, 513 root_cause=root_cause, 514 user_message=user_msg, 515 policy_decision=policy_decision, 516 execution_action=execution_action, 517 should_abort=should_abort, 518 diagnostic_reason=diagnostic_reason, 519 tool_name=tool_name, 520 ) 521 522 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 523 config["configurable"][CONFIG_LAST_VIOLATION_KEY] = resolution 524 525 return resolution 526 527 def resolve_tool_exception( 528 self, 529 exc: Exception, 530 config: Optional[Dict[str, Any]], 531 ) -> ViolationResolution: 532 """Resolve an uncaught tool exception into a structured ViolationResolution. 533 534 Args: 535 exc: Exception raised during tool execution. 536 config: Optional RunnableConfig dictionary. 537 538 Returns: 539 ViolationResolution: Structured violation resolution object. 540 """ 541 tool_name = getattr(exc, "tool_name", "unknown_tool") 542 diagnostic_reason = getattr(exc, "diagnostic_reason", None) or str(exc) 543 caller_identity = IdentityBridge.extract_from_config(config) 544 return self.record_tool_violation( 545 tool_name, 546 diagnostic_reason, 547 True, 548 config, 549 caller_identity, 550 )
PEP violation handler integrating retry tracking, abuse throttling, and fallback generation.
340 def __init__( 341 self, 342 pdp_engine: PolicyDecisionEngine, 343 retry_tracker: RetryBudgetTracker, 344 abuse_tracker: AbuseThrottleTracker, 345 ): 346 """Initialize ToolViolationHandler. 347 348 Args: 349 pdp_engine: PDP decision engine for evaluating and logging violations. 350 retry_tracker: RetryBudgetTracker instance. 351 abuse_tracker: AbuseThrottleTracker instance. 352 """ 353 self._pdp_engine = pdp_engine 354 self._retry_tracker = retry_tracker 355 self._abuse_tracker = abuse_tracker 356 if hasattr(self._pdp_engine, "register_invalidation_callback"): 357 self._pdp_engine.register_invalidation_callback(self.reset_abuse)
Initialize ToolViolationHandler.
Args: pdp_engine: PDP decision engine for evaluating and logging violations. retry_tracker: RetryBudgetTracker instance. abuse_tracker: AbuseThrottleTracker instance.
359 def reset_abuse(self, *args: Any, **kwargs: Any) -> None: 360 """Reset abuse tracking for a specific subject or all subjects. 361 362 Args: 363 *args: Optional positional subject_id. 364 **kwargs: Optional keyword subject_id. 365 """ 366 self._abuse_tracker.reset(*args, **kwargs)
Reset abuse tracking for a specific subject or all subjects.
Args: *args: Optional positional subject_id. **kwargs: Optional keyword subject_id.
368 @classmethod 369 def create( 370 cls, 371 pdp_engine: PolicyDecisionEngine, 372 retry_tracker: RetryBudgetTracker, 373 abuse_tracker: AbuseThrottleTracker, 374 ) -> "ToolViolationHandler": 375 """Factory method constructing a ToolViolationHandler. 376 377 Args: 378 pdp_engine: PDP decision engine. 379 retry_tracker: Tracker for model feedback retries. 380 abuse_tracker: Tracker for subject-level abuse denial bursts. 381 382 Returns: 383 ToolViolationHandler: New handler instance. 384 """ 385 return cls( 386 pdp_engine=pdp_engine, 387 retry_tracker=retry_tracker, 388 abuse_tracker=abuse_tracker, 389 )
Factory method constructing a ToolViolationHandler.
Args: pdp_engine: PDP decision engine. retry_tracker: Tracker for model feedback retries. abuse_tracker: Tracker for subject-level abuse denial bursts.
Returns: ToolViolationHandler: New handler instance.
391 @classmethod 392 def default_handler(cls, pdp_engine: PolicyDecisionEngine) -> "ToolViolationHandler": 393 """Factory method constructing default handler with standard limits. 394 395 Args: 396 pdp_engine: PDP decision engine instance. 397 398 Returns: 399 ToolViolationHandler: Handler initialized with standard 2-retry budget and 60s sliding abuse window. 400 """ 401 retry_tracker = RetryBudgetTracker.create(max_feedback_violations=DEFAULT_MAX_FEEDBACK_VIOLATIONS) 402 abuse_tracker = AbuseThrottleTracker.create( 403 window_seconds=DEFAULT_ABUSE_WINDOW_SECONDS, 404 max_denials=DEFAULT_ABUSE_MAX_DENIALS, 405 ) 406 return cls.create( 407 pdp_engine=pdp_engine, 408 retry_tracker=retry_tracker, 409 abuse_tracker=abuse_tracker, 410 )
Factory method constructing default handler with standard limits.
Args: pdp_engine: PDP decision engine instance.
Returns: ToolViolationHandler: Handler initialized with standard 2-retry budget and 60s sliding abuse window.
412 @property 413 def pdp_engine(self) -> PolicyDecisionEngine: 414 """Get the bound PolicyDecisionEngine.""" 415 return self._pdp_engine
Get the bound PolicyDecisionEngine.
417 @property 418 def retry_tracker(self) -> RetryBudgetTracker: 419 """Get the bound RetryBudgetTracker.""" 420 return self._retry_tracker
Get the bound RetryBudgetTracker.
422 @property 423 def abuse_tracker(self) -> AbuseThrottleTracker: 424 """Get the bound AbuseThrottleTracker.""" 425 return self._abuse_tracker
Get the bound AbuseThrottleTracker.
427 def record_tool_violation( 428 self, 429 tool_name: str, 430 diagnostic_reason: str, 431 is_mutative: bool, 432 config: Optional[Dict[str, Any]], 433 caller_identity: Optional[SecurityIdentity], 434 ) -> ViolationResolution: 435 """Record a tool policy violation, tracks abuse/retries, and returns structured resolution. 436 437 Args: 438 tool_name: Name of tool whose execution was denied. 439 diagnostic_reason: Diagnostic reason string from PDP decision. 440 is_mutative: True if tool modifies system state, triggering immediate hard abort. 441 config: Optional RunnableConfig dictionary for tracking state and retry counts. 442 caller_identity: Optional SecurityIdentity envelope of calling subject. 443 444 Returns: 445 ViolationResolution: Structured resolution detailing telemetry, status, and abort flags. 446 """ 447 is_budget_exceeded = self._retry_tracker.is_budget_exceeded(config) 448 is_abusing = False 449 450 if caller_identity is not None and self._abuse_tracker.record_denial(caller_identity.subject_id, time.time()): 451 is_abusing = True 452 audit_mgr = getattr(self._pdp_engine, "audit_manager", None) 453 if audit_mgr is not None: 454 audit_mgr.record_event( 455 AuditEvent.create( 456 subject_id=caller_identity.subject_id, 457 tenant_id=caller_identity.tenant_id, 458 action=ACTION_TOOL_EXECUTE, 459 resource=tool_name, 460 decision="HARD_ABORT", 461 diagnostic_reason="Abuse throttle threshold exceeded: Brute-force scanning detected across window", 462 severity=SEVERITY_CRITICAL_ALERT, 463 metadata={"tool_name": tool_name}, 464 enforcement_point=PEP_TOOL_INTERCEPTOR, 465 reason_code=EVENT_POLICY_BRUTE_FORCE_SCANNING, 466 ) 467 ) 468 469 if is_budget_exceeded: 470 audit_mgr = getattr(self._pdp_engine, "audit_manager", None) 471 if audit_mgr is not None and caller_identity is not None: 472 audit_mgr.record_event( 473 AuditEvent.create( 474 subject_id=caller_identity.subject_id, 475 tenant_id=caller_identity.tenant_id, 476 action=ACTION_TOOL_EXECUTE, 477 resource=tool_name, 478 decision="HARD_ABORT", 479 diagnostic_reason="Retry budget exceeded: Policy brute force probe detected", 480 severity=SEVERITY_CRITICAL_ALERT, 481 metadata={"tool_name": tool_name}, 482 enforcement_point=PEP_TOOL_INTERCEPTOR, 483 reason_code=EVENT_POLICY_BRUTE_FORCE_DETECTED, 484 ) 485 ) 486 root_cause = "Policy Abort: POLICY_BRUTE_FORCE_SCANNING" 487 user_msg = f"Execution halted: retry budget exceeded (POLICY_BRUTE_FORCE_DETECTED on {tool_name})" 488 action_entry = f"Blocked execution of tool '{tool_name}' (retry budget exceeded)" 489 should_abort = True 490 status = STATUS_BLOCKED_BY_POLICY 491 policy_decision = POLICY_STATUS_BLOCKED 492 execution_action = EXECUTION_ACTION_ABORTED 493 elif is_mutative: 494 root_cause = f"Policy violation: Access denied for tool '{tool_name}' ({diagnostic_reason})" 495 user_msg = f"Policy violation: Access denied for tool '{tool_name}' ({diagnostic_reason})" 496 action_entry = f"Blocked execution of tool '{tool_name}' ({diagnostic_reason})" 497 should_abort = True 498 status = STATUS_BLOCKED_BY_POLICY 499 policy_decision = POLICY_STATUS_BLOCKED 500 execution_action = EXECUTION_ACTION_ABORTED 501 else: 502 root_cause = f"Policy restriction: Informational tool '{tool_name}' access denied ({diagnostic_reason})" 503 user_msg = f"Policy restriction: Informational tool '{tool_name}' access denied ({diagnostic_reason})" 504 action_entry = f"Blocked execution of tool '{tool_name}' ({diagnostic_reason})" 505 should_abort = is_abusing 506 status = STATUS_BLOCKED_BY_POLICY if is_abusing else "ACCESS_DENIED" 507 policy_decision = POLICY_STATUS_BLOCKED if is_abusing else POLICY_STATUS_FEEDBACK 508 execution_action = EXECUTION_ACTION_ABORTED if is_abusing else EXECUTION_ACTION_FEEDBACK 509 510 resolution = ViolationResolution.create( 511 status=status, 512 action_entry=action_entry, 513 root_cause=root_cause, 514 user_message=user_msg, 515 policy_decision=policy_decision, 516 execution_action=execution_action, 517 should_abort=should_abort, 518 diagnostic_reason=diagnostic_reason, 519 tool_name=tool_name, 520 ) 521 522 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 523 config["configurable"][CONFIG_LAST_VIOLATION_KEY] = resolution 524 525 return resolution
Record a tool policy violation, tracks abuse/retries, and returns structured resolution.
Args: tool_name: Name of tool whose execution was denied. diagnostic_reason: Diagnostic reason string from PDP decision. is_mutative: True if tool modifies system state, triggering immediate hard abort. config: Optional RunnableConfig dictionary for tracking state and retry counts. caller_identity: Optional SecurityIdentity envelope of calling subject.
Returns: ViolationResolution: Structured resolution detailing telemetry, status, and abort flags.
527 def resolve_tool_exception( 528 self, 529 exc: Exception, 530 config: Optional[Dict[str, Any]], 531 ) -> ViolationResolution: 532 """Resolve an uncaught tool exception into a structured ViolationResolution. 533 534 Args: 535 exc: Exception raised during tool execution. 536 config: Optional RunnableConfig dictionary. 537 538 Returns: 539 ViolationResolution: Structured violation resolution object. 540 """ 541 tool_name = getattr(exc, "tool_name", "unknown_tool") 542 diagnostic_reason = getattr(exc, "diagnostic_reason", None) or str(exc) 543 caller_identity = IdentityBridge.extract_from_config(config) 544 return self.record_tool_violation( 545 tool_name, 546 diagnostic_reason, 547 True, 548 config, 549 caller_identity, 550 )
Resolve an uncaught tool exception into a structured ViolationResolution.
Args: exc: Exception raised during tool execution. config: Optional RunnableConfig dictionary.
Returns: ViolationResolution: Structured violation resolution object.
175class RetryBudgetTracker: 176 """Invocation-scoped retry budget tracker preventing recursive LLM retry loops.""" 177 178 def __init__(self, max_feedback_violations: int): 179 """Initialize RetryBudgetTracker. 180 181 Args: 182 max_feedback_violations: Maximum feedback violation retry allowance before escalating to hard abort. 183 """ 184 self._max_feedback_violations = max_feedback_violations 185 186 @classmethod 187 def create(cls, max_feedback_violations: int) -> "RetryBudgetTracker": 188 """Factory method to construct RetryBudgetTracker. 189 190 Args: 191 max_feedback_violations: Maximum retry threshold integer. 192 193 Returns: 194 RetryBudgetTracker: Configured tracker instance. 195 """ 196 return cls(max_feedback_violations=max_feedback_violations) 197 198 @property 199 def max_feedback_violations(self) -> int: 200 """Get maximum feedback violation retry allowance.""" 201 return self._max_feedback_violations 202 203 def get_retry_count(self, config: Optional[Dict[str, Any]]) -> int: 204 """Retrieve the current retry violation count from invocation configuration. 205 206 Args: 207 config: RunnableConfig dictionary containing configurable metadata. 208 209 Returns: 210 int: Current integer retry count. 211 """ 212 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 213 return int(config["configurable"].get(CONFIG_RETRY_COUNT_KEY, 0)) 214 return 0 215 216 def increment_retry_count(self, config: Optional[Dict[str, Any]]) -> int: 217 """Increment and persist the retry count inside the invocation configuration. 218 219 Args: 220 config: Target RunnableConfig dictionary. 221 222 Returns: 223 int: Newly updated retry count integer. 224 """ 225 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 226 count = int(config["configurable"].get(CONFIG_RETRY_COUNT_KEY, 0)) + 1 227 config["configurable"][CONFIG_RETRY_COUNT_KEY] = count 228 return count 229 return 0 230 231 def is_budget_exceeded(self, config: Optional[Dict[str, Any]]) -> bool: 232 """Check if invocation-scoped retries have exceeded the maximum feedback limit. 233 234 Args: 235 config: Target RunnableConfig dictionary. 236 237 Returns: 238 bool: True if retry budget is exhausted, False otherwise. 239 """ 240 return self.get_retry_count(config) > self._max_feedback_violations 241 242 def reset(self, config: Optional[Dict[str, Any]]) -> None: 243 """Reset the invocation retry counter to zero. 244 245 Args: 246 config: Target RunnableConfig dictionary. 247 """ 248 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 249 config["configurable"][CONFIG_RETRY_COUNT_KEY] = 0
Invocation-scoped retry budget tracker preventing recursive LLM retry loops.
178 def __init__(self, max_feedback_violations: int): 179 """Initialize RetryBudgetTracker. 180 181 Args: 182 max_feedback_violations: Maximum feedback violation retry allowance before escalating to hard abort. 183 """ 184 self._max_feedback_violations = max_feedback_violations
Initialize RetryBudgetTracker.
Args: max_feedback_violations: Maximum feedback violation retry allowance before escalating to hard abort.
186 @classmethod 187 def create(cls, max_feedback_violations: int) -> "RetryBudgetTracker": 188 """Factory method to construct RetryBudgetTracker. 189 190 Args: 191 max_feedback_violations: Maximum retry threshold integer. 192 193 Returns: 194 RetryBudgetTracker: Configured tracker instance. 195 """ 196 return cls(max_feedback_violations=max_feedback_violations)
Factory method to construct RetryBudgetTracker.
Args: max_feedback_violations: Maximum retry threshold integer.
Returns: RetryBudgetTracker: Configured tracker instance.
198 @property 199 def max_feedback_violations(self) -> int: 200 """Get maximum feedback violation retry allowance.""" 201 return self._max_feedback_violations
Get maximum feedback violation retry allowance.
203 def get_retry_count(self, config: Optional[Dict[str, Any]]) -> int: 204 """Retrieve the current retry violation count from invocation configuration. 205 206 Args: 207 config: RunnableConfig dictionary containing configurable metadata. 208 209 Returns: 210 int: Current integer retry count. 211 """ 212 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 213 return int(config["configurable"].get(CONFIG_RETRY_COUNT_KEY, 0)) 214 return 0
Retrieve the current retry violation count from invocation configuration.
Args: config: RunnableConfig dictionary containing configurable metadata.
Returns: int: Current integer retry count.
216 def increment_retry_count(self, config: Optional[Dict[str, Any]]) -> int: 217 """Increment and persist the retry count inside the invocation configuration. 218 219 Args: 220 config: Target RunnableConfig dictionary. 221 222 Returns: 223 int: Newly updated retry count integer. 224 """ 225 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 226 count = int(config["configurable"].get(CONFIG_RETRY_COUNT_KEY, 0)) + 1 227 config["configurable"][CONFIG_RETRY_COUNT_KEY] = count 228 return count 229 return 0
Increment and persist the retry count inside the invocation configuration.
Args: config: Target RunnableConfig dictionary.
Returns: int: Newly updated retry count integer.
231 def is_budget_exceeded(self, config: Optional[Dict[str, Any]]) -> bool: 232 """Check if invocation-scoped retries have exceeded the maximum feedback limit. 233 234 Args: 235 config: Target RunnableConfig dictionary. 236 237 Returns: 238 bool: True if retry budget is exhausted, False otherwise. 239 """ 240 return self.get_retry_count(config) > self._max_feedback_violations
Check if invocation-scoped retries have exceeded the maximum feedback limit.
Args: config: Target RunnableConfig dictionary.
Returns: bool: True if retry budget is exhausted, False otherwise.
242 def reset(self, config: Optional[Dict[str, Any]]) -> None: 243 """Reset the invocation retry counter to zero. 244 245 Args: 246 config: Target RunnableConfig dictionary. 247 """ 248 if isinstance(config, dict) and isinstance(config.get("configurable"), dict): 249 config["configurable"][CONFIG_RETRY_COUNT_KEY] = 0
Reset the invocation retry counter to zero.
Args: config: Target RunnableConfig dictionary.
252class AbuseThrottleTracker: 253 """Sliding-window abuse throttle tracker detecting rapid cross-invocation denial floods.""" 254 255 def __init__(self, window_seconds: float, max_denials: int): 256 """Initialize AbuseThrottleTracker. 257 258 Args: 259 window_seconds: Duration of sliding evaluation window in seconds. 260 max_denials: Maximum allowed denial events within window before throttling. 261 """ 262 self._window_seconds = window_seconds 263 self._max_denials = max_denials 264 self._denial_history: Dict[str, List[float]] = {} 265 266 @classmethod 267 def create(cls, window_seconds: float, max_denials: int) -> "AbuseThrottleTracker": 268 """Factory method to construct AbuseThrottleTracker. 269 270 Args: 271 window_seconds: Window duration in seconds. 272 max_denials: Threshold count of denials. 273 274 Returns: 275 AbuseThrottleTracker: Configured tracker instance. 276 """ 277 return cls(window_seconds=window_seconds, max_denials=max_denials) 278 279 @property 280 def window_seconds(self) -> float: 281 """Get sliding window duration in seconds.""" 282 return self._window_seconds 283 284 @property 285 def max_denials(self) -> int: 286 """Get maximum allowed denials threshold.""" 287 return self._max_denials 288 289 def _prune(self, subject_id: str, current_time: float) -> List[float]: 290 cutoff = current_time - self._window_seconds 291 timestamps = [t for t in self._denial_history.get(subject_id, []) if t >= cutoff] 292 self._denial_history[subject_id] = timestamps 293 return timestamps 294 295 def record_denial(self, subject_id: str, timestamp: float) -> bool: 296 """Record a denial occurrence for a subject and check if abuse threshold is breached. 297 298 Args: 299 subject_id: Subject identifier. 300 timestamp: Epoch timestamp in seconds. 301 302 Returns: 303 bool: True if subject exceeds abuse threshold (is abusing), False otherwise. 304 """ 305 timestamps = self._prune(subject_id, timestamp) 306 timestamps.append(timestamp) 307 self._denial_history[subject_id] = timestamps 308 return len(timestamps) >= self._max_denials 309 310 def is_throttled(self, subject_id: str, timestamp: float) -> bool: 311 """Check if subject is currently throttled due to recent denial density. 312 313 Args: 314 subject_id: Subject identifier. 315 timestamp: Epoch timestamp in seconds. 316 317 Returns: 318 bool: True if subject is throttled, False otherwise. 319 """ 320 timestamps = self._prune(subject_id, timestamp) 321 return len(timestamps) >= self._max_denials 322 323 def reset(self, *args: Any, **kwargs: Any) -> None: 324 """Reset denial history for a specific subject or all subjects. 325 326 Args: 327 *args: Optional positional subject_id. 328 **kwargs: Optional keyword subject_id. 329 """ 330 subject_id = args[0] if len(args) > 0 else kwargs.get("subject_id") 331 if subject_id is not None: 332 self._denial_history.pop(str(subject_id), None) 333 else: 334 self._denial_history.clear()
Sliding-window abuse throttle tracker detecting rapid cross-invocation denial floods.
255 def __init__(self, window_seconds: float, max_denials: int): 256 """Initialize AbuseThrottleTracker. 257 258 Args: 259 window_seconds: Duration of sliding evaluation window in seconds. 260 max_denials: Maximum allowed denial events within window before throttling. 261 """ 262 self._window_seconds = window_seconds 263 self._max_denials = max_denials 264 self._denial_history: Dict[str, List[float]] = {}
Initialize AbuseThrottleTracker.
Args: window_seconds: Duration of sliding evaluation window in seconds. max_denials: Maximum allowed denial events within window before throttling.
266 @classmethod 267 def create(cls, window_seconds: float, max_denials: int) -> "AbuseThrottleTracker": 268 """Factory method to construct AbuseThrottleTracker. 269 270 Args: 271 window_seconds: Window duration in seconds. 272 max_denials: Threshold count of denials. 273 274 Returns: 275 AbuseThrottleTracker: Configured tracker instance. 276 """ 277 return cls(window_seconds=window_seconds, max_denials=max_denials)
Factory method to construct AbuseThrottleTracker.
Args: window_seconds: Window duration in seconds. max_denials: Threshold count of denials.
Returns: AbuseThrottleTracker: Configured tracker instance.
279 @property 280 def window_seconds(self) -> float: 281 """Get sliding window duration in seconds.""" 282 return self._window_seconds
Get sliding window duration in seconds.
284 @property 285 def max_denials(self) -> int: 286 """Get maximum allowed denials threshold.""" 287 return self._max_denials
Get maximum allowed denials threshold.
295 def record_denial(self, subject_id: str, timestamp: float) -> bool: 296 """Record a denial occurrence for a subject and check if abuse threshold is breached. 297 298 Args: 299 subject_id: Subject identifier. 300 timestamp: Epoch timestamp in seconds. 301 302 Returns: 303 bool: True if subject exceeds abuse threshold (is abusing), False otherwise. 304 """ 305 timestamps = self._prune(subject_id, timestamp) 306 timestamps.append(timestamp) 307 self._denial_history[subject_id] = timestamps 308 return len(timestamps) >= self._max_denials
Record a denial occurrence for a subject and check if abuse threshold is breached.
Args: subject_id: Subject identifier. timestamp: Epoch timestamp in seconds.
Returns: bool: True if subject exceeds abuse threshold (is abusing), False otherwise.
310 def is_throttled(self, subject_id: str, timestamp: float) -> bool: 311 """Check if subject is currently throttled due to recent denial density. 312 313 Args: 314 subject_id: Subject identifier. 315 timestamp: Epoch timestamp in seconds. 316 317 Returns: 318 bool: True if subject is throttled, False otherwise. 319 """ 320 timestamps = self._prune(subject_id, timestamp) 321 return len(timestamps) >= self._max_denials
Check if subject is currently throttled due to recent denial density.
Args: subject_id: Subject identifier. timestamp: Epoch timestamp in seconds.
Returns: bool: True if subject is throttled, False otherwise.
323 def reset(self, *args: Any, **kwargs: Any) -> None: 324 """Reset denial history for a specific subject or all subjects. 325 326 Args: 327 *args: Optional positional subject_id. 328 **kwargs: Optional keyword subject_id. 329 """ 330 subject_id = args[0] if len(args) > 0 else kwargs.get("subject_id") 331 if subject_id is not None: 332 self._denial_history.pop(str(subject_id), None) 333 else: 334 self._denial_history.clear()
Reset denial history for a specific subject or all subjects.
Args: *args: Optional positional subject_id. **kwargs: Optional keyword subject_id.
37class ViolationResolution: 38 """Represents the structured resolution of a policy violation. 39 40 Attributes: 41 status (str): Overall final status token. 42 action_entry (str): Telemetry action string to record. 43 root_cause (str): Diagnostic root cause classification. 44 user_message (str): Sanitized explanation for end-user. 45 policy_decision (str): Policy evaluation outcome (e.g., BLOCKED_POLICY, FEEDBACK_REFUSAL). 46 execution_action (str): Runtime execution action (e.g., ABORTED, SYNTHETIC_PAYLOAD). 47 should_abort (bool): Whether agent loop must abort immediately. 48 diagnostic_reason (Optional[str]): Detailed diagnostic reason code. 49 tool_name (Optional[str]): Target tool name associated with violation. 50 """ 51 52 def __init__( 53 self, 54 status: str, 55 action_entry: str, 56 root_cause: str, 57 user_message: str, 58 policy_decision: str, 59 execution_action: str, 60 should_abort: bool, 61 diagnostic_reason: Optional[str], 62 tool_name: Optional[str], 63 ): 64 """Initialize ViolationResolution. 65 66 Args: 67 status: Overall final status token (e.g. BLOCKED_BY_POLICY, ACCESS_DENIED). 68 action_entry: Telemetry log entry action description. 69 root_cause: Diagnostic root cause classification. 70 user_message: Sanitized explanation suitable for end-user. 71 policy_decision: Policy evaluation verdict token (e.g. BLOCKED_POLICY, FEEDBACK_REFUSAL). 72 execution_action: Runtime execution action (e.g. ABORTED, SYNTHETIC_PAYLOAD). 73 should_abort: True if agent loop execution must abort immediately. 74 diagnostic_reason: Detailed diagnostic reason code. 75 tool_name: Name of target tool associated with violation. 76 """ 77 self._status = status 78 self._action_entry = action_entry 79 self._root_cause = root_cause 80 self._user_message = user_message 81 self._policy_decision = policy_decision 82 self._execution_action = execution_action 83 self._should_abort = should_abort 84 self._diagnostic_reason = diagnostic_reason 85 self._tool_name = tool_name 86 87 @classmethod 88 def create(cls, *args: Any, **kwargs: Any) -> "ViolationResolution": 89 """Factory method to construct ViolationResolution. 90 91 Args: 92 *args: Optional positional arguments (status, action_entry, root_cause, user_message, policy_decision, execution_action, should_abort, diagnostic_reason, tool_name). 93 **kwargs: Optional keyword arguments. 94 95 Returns: 96 ViolationResolution: Constructed structured resolution object. 97 """ 98 status = str(kwargs.get("status", args[0] if len(args) > 0 else "")) 99 action_entry = str(kwargs.get("action_entry", args[1] if len(args) > 1 else "")) 100 root_cause = str(kwargs.get("root_cause", args[2] if len(args) > 2 else "")) 101 user_message = str(kwargs.get("user_message", args[3] if len(args) > 3 else "")) 102 policy_decision = str(kwargs.get("policy_decision", kwargs.get("remediation_security", args[4] if len(args) > 4 else ""))) 103 execution_action = str(kwargs.get("execution_action", kwargs.get("remediation_execution", args[5] if len(args) > 5 else ""))) 104 should_abort = bool(kwargs.get("should_abort", args[6] if len(args) > 6 else False)) 105 diagnostic_reason = kwargs.get("diagnostic_reason", args[7] if len(args) > 7 else None) 106 tool_name = kwargs.get("tool_name", args[8] if len(args) > 8 else None) 107 return cls( 108 status, 109 action_entry, 110 root_cause, 111 user_message, 112 policy_decision, 113 execution_action, 114 should_abort, 115 diagnostic_reason, 116 tool_name, 117 ) 118 119 @property 120 def status(self) -> str: 121 """Get overall violation status token.""" 122 return self._status 123 124 @property 125 def action_entry(self) -> str: 126 """Get log action description.""" 127 return self._action_entry 128 129 @property 130 def root_cause(self) -> str: 131 """Get diagnostic root cause token.""" 132 return self._root_cause 133 134 @property 135 def user_message(self) -> str: 136 """Get sanitized user-facing error message.""" 137 return self._user_message 138 139 @property 140 def policy_decision(self) -> str: 141 """Get policy evaluation decision verdict token.""" 142 return self._policy_decision 143 144 @property 145 def execution_action(self) -> str: 146 """Get runtime execution remediation action.""" 147 return self._execution_action 148 149 @property 150 def remediation_security(self) -> str: 151 """Backward-compatibility accessor for policy_decision.""" 152 return self._policy_decision 153 154 @property 155 def remediation_execution(self) -> str: 156 """Backward-compatibility accessor for execution_action.""" 157 return self._execution_action 158 159 @property 160 def should_abort(self) -> bool: 161 """Check whether the agent execution loop should abort immediately.""" 162 return self._should_abort 163 164 @property 165 def diagnostic_reason(self) -> Optional[str]: 166 """Get detailed diagnostic reason code.""" 167 return self._diagnostic_reason 168 169 @property 170 def tool_name(self) -> Optional[str]: 171 """Get name of tool associated with violation, if applicable.""" 172 return self._tool_name
Represents the structured resolution of a policy violation.
Attributes: status (str): Overall final status token. action_entry (str): Telemetry action string to record. root_cause (str): Diagnostic root cause classification. user_message (str): Sanitized explanation for end-user. policy_decision (str): Policy evaluation outcome (e.g., BLOCKED_POLICY, FEEDBACK_REFUSAL). execution_action (str): Runtime execution action (e.g., ABORTED, SYNTHETIC_PAYLOAD). should_abort (bool): Whether agent loop must abort immediately. diagnostic_reason (Optional[str]): Detailed diagnostic reason code. tool_name (Optional[str]): Target tool name associated with violation.
52 def __init__( 53 self, 54 status: str, 55 action_entry: str, 56 root_cause: str, 57 user_message: str, 58 policy_decision: str, 59 execution_action: str, 60 should_abort: bool, 61 diagnostic_reason: Optional[str], 62 tool_name: Optional[str], 63 ): 64 """Initialize ViolationResolution. 65 66 Args: 67 status: Overall final status token (e.g. BLOCKED_BY_POLICY, ACCESS_DENIED). 68 action_entry: Telemetry log entry action description. 69 root_cause: Diagnostic root cause classification. 70 user_message: Sanitized explanation suitable for end-user. 71 policy_decision: Policy evaluation verdict token (e.g. BLOCKED_POLICY, FEEDBACK_REFUSAL). 72 execution_action: Runtime execution action (e.g. ABORTED, SYNTHETIC_PAYLOAD). 73 should_abort: True if agent loop execution must abort immediately. 74 diagnostic_reason: Detailed diagnostic reason code. 75 tool_name: Name of target tool associated with violation. 76 """ 77 self._status = status 78 self._action_entry = action_entry 79 self._root_cause = root_cause 80 self._user_message = user_message 81 self._policy_decision = policy_decision 82 self._execution_action = execution_action 83 self._should_abort = should_abort 84 self._diagnostic_reason = diagnostic_reason 85 self._tool_name = tool_name
Initialize ViolationResolution.
Args: status: Overall final status token (e.g. BLOCKED_BY_POLICY, ACCESS_DENIED). action_entry: Telemetry log entry action description. root_cause: Diagnostic root cause classification. user_message: Sanitized explanation suitable for end-user. policy_decision: Policy evaluation verdict token (e.g. BLOCKED_POLICY, FEEDBACK_REFUSAL). execution_action: Runtime execution action (e.g. ABORTED, SYNTHETIC_PAYLOAD). should_abort: True if agent loop execution must abort immediately. diagnostic_reason: Detailed diagnostic reason code. tool_name: Name of target tool associated with violation.
87 @classmethod 88 def create(cls, *args: Any, **kwargs: Any) -> "ViolationResolution": 89 """Factory method to construct ViolationResolution. 90 91 Args: 92 *args: Optional positional arguments (status, action_entry, root_cause, user_message, policy_decision, execution_action, should_abort, diagnostic_reason, tool_name). 93 **kwargs: Optional keyword arguments. 94 95 Returns: 96 ViolationResolution: Constructed structured resolution object. 97 """ 98 status = str(kwargs.get("status", args[0] if len(args) > 0 else "")) 99 action_entry = str(kwargs.get("action_entry", args[1] if len(args) > 1 else "")) 100 root_cause = str(kwargs.get("root_cause", args[2] if len(args) > 2 else "")) 101 user_message = str(kwargs.get("user_message", args[3] if len(args) > 3 else "")) 102 policy_decision = str(kwargs.get("policy_decision", kwargs.get("remediation_security", args[4] if len(args) > 4 else ""))) 103 execution_action = str(kwargs.get("execution_action", kwargs.get("remediation_execution", args[5] if len(args) > 5 else ""))) 104 should_abort = bool(kwargs.get("should_abort", args[6] if len(args) > 6 else False)) 105 diagnostic_reason = kwargs.get("diagnostic_reason", args[7] if len(args) > 7 else None) 106 tool_name = kwargs.get("tool_name", args[8] if len(args) > 8 else None) 107 return cls( 108 status, 109 action_entry, 110 root_cause, 111 user_message, 112 policy_decision, 113 execution_action, 114 should_abort, 115 diagnostic_reason, 116 tool_name, 117 )
Factory method to construct ViolationResolution.
Args: *args: Optional positional arguments (status, action_entry, root_cause, user_message, policy_decision, execution_action, should_abort, diagnostic_reason, tool_name). **kwargs: Optional keyword arguments.
Returns: ViolationResolution: Constructed structured resolution object.
119 @property 120 def status(self) -> str: 121 """Get overall violation status token.""" 122 return self._status
Get overall violation status token.
124 @property 125 def action_entry(self) -> str: 126 """Get log action description.""" 127 return self._action_entry
Get log action description.
129 @property 130 def root_cause(self) -> str: 131 """Get diagnostic root cause token.""" 132 return self._root_cause
Get diagnostic root cause token.
134 @property 135 def user_message(self) -> str: 136 """Get sanitized user-facing error message.""" 137 return self._user_message
Get sanitized user-facing error message.
139 @property 140 def policy_decision(self) -> str: 141 """Get policy evaluation decision verdict token.""" 142 return self._policy_decision
Get policy evaluation decision verdict token.
144 @property 145 def execution_action(self) -> str: 146 """Get runtime execution remediation action.""" 147 return self._execution_action
Get runtime execution remediation action.
149 @property 150 def remediation_security(self) -> str: 151 """Backward-compatibility accessor for policy_decision.""" 152 return self._policy_decision
Backward-compatibility accessor for policy_decision.
154 @property 155 def remediation_execution(self) -> str: 156 """Backward-compatibility accessor for execution_action.""" 157 return self._execution_action
Backward-compatibility accessor for execution_action.
159 @property 160 def should_abort(self) -> bool: 161 """Check whether the agent execution loop should abort immediately.""" 162 return self._should_abort
Check whether the agent execution loop should abort immediately.