1212
1313from __future__ import annotations
1414
15+ import concurrent .futures
1516import hashlib
1617import json
1718import re
@@ -57,6 +58,8 @@ class StageResult:
5758 stripped_fields : list [str ] | None = None
5859 sensitivity_tags : list [str ] = field (default_factory = list )
5960 injection_pattern : str | None = None
61+ injection_scanner : str | None = None # INJECT-003: which scanner triggered the deny
62+ injection_score : float | None = None # INJECT-003: confidence score if available
6063
6164
6265@dataclass
@@ -70,6 +73,9 @@ class InspectionResult:
7073 stage_results : dict [str , str ]
7174 response_payload_hash : str | None
7275 modified_response : bytes | None # None if not modified (allow as-is)
76+ # INJECT-003: scanner attribution for audit chain context
77+ injection_scanner : str | None = None # which scanner detected: "agt_mcp", "agt_detector", "regex", "timeout"
78+ injection_score : float | None = None # confidence score (0.0–1.0) if available
7379
7480
7581def _sha256_hex (data : bytes ) -> str :
@@ -187,12 +193,15 @@ def _stage4_injection_detection(
187193 result = _agt_detector .detect (response_text )
188194 if result .is_injection :
189195 pattern_name = result .injection_type .value if hasattr (result .injection_type , "value" ) else str (result .injection_type )
196+ score = float (result .confidence ) if hasattr (result , "confidence" ) else None
190197 # Log pattern name and bounded window, not full content
191198 return StageResult (
192199 stage = "injection" ,
193200 decision = "deny" ,
194201 reason = f"AGT injection detected: { pattern_name } (confidence={ result .confidence :.2f} )" ,
195202 injection_pattern = f"agt:{ pattern_name } " ,
203+ injection_scanner = "agt_detector" ,
204+ injection_score = score ,
196205 )
197206 return StageResult (stage = "injection" , decision = "allow" )
198207 except Exception : # nosec B110
@@ -211,6 +220,7 @@ def _stage4_injection_detection(
211220 decision = "deny" ,
212221 reason = f"injection pattern '{ name } ' matched near { context_window } " ,
213222 injection_pattern = name ,
223+ injection_scanner = "regex" ,
214224 )
215225 return StageResult (stage = "injection" , decision = "allow" )
216226
@@ -357,9 +367,11 @@ def __init__(
357367 self ,
358368 max_response_size_bytes : int = 2 * 1024 * 1024 ,
359369 custom_injection_patterns : list [tuple [re .Pattern [str ], str ]] | None = None ,
370+ scanner_timeout_seconds : float = 5.0 ,
360371 ) -> None :
361372 self ._max_bytes = max_response_size_bytes
362373 self ._injection_patterns = custom_injection_patterns
374+ self ._scanner_timeout = scanner_timeout_seconds
363375
364376 # Instantiate AGT components once per pipeline instance
365377 self ._agt_injection_detector : Any = None
@@ -450,39 +462,72 @@ def run(
450462 stage_results = stage_results ,
451463 response_payload_hash = response_payload_hash ,
452464 modified_response = None ,
465+ injection_scanner = "utf8_guard" ,
453466 )
454467
455468 agt_mcp_denied = False
469+ injection_scanner : str | None = None
470+ injection_score : float | None = None
456471
457472 # AGT MCPResponseScanner catches MCP-specific threats (tool poisoning in responses)
473+ # INJECT-002: bounded timeout so a slow/unresponsive AGT service cannot block
474+ # worker slots indefinitely. Treat timeout as deny (fail-safe).
458475 if self ._agt_response_scanner is not None :
476+ scanner = self ._agt_response_scanner
477+ tool = catalog_entry .tool_name
459478 try :
460- agt_scan = self . _agt_response_scanner . scan_response (
461- response_text , tool_name = catalog_entry . tool_name
462- )
479+ with concurrent . futures . ThreadPoolExecutor ( max_workers = 1 ) as ex :
480+ fut = ex . submit ( scanner . scan_response , response_text , tool )
481+ agt_scan = fut . result ( timeout = self . _scanner_timeout )
463482 if not agt_scan .is_safe :
464483 threat_name = str (agt_scan .threats [0 ]) if agt_scan .threats else "mcp_threat"
465484 deny_reasons .append (f"AGT MCPResponseScanner: { threat_name } " )
466485 injection_pattern = f"agt_mcp:{ threat_name } "
486+ injection_scanner = "agt_mcp"
467487 # POLICY-006: record deny from AGT scanner before running regex stage;
468488 # regex stage below must not overwrite a deny with allow.
469489 stage_results ["injection" ] = "deny"
470490 agt_mcp_denied = True
491+ except concurrent .futures .TimeoutError :
492+ # INJECT-002: scanner timed out — deny to prevent bypass via slow AGT
493+ deny_reasons .append (f"AGT MCPResponseScanner timed out after { self ._scanner_timeout } s" )
494+ injection_pattern = "scanner_timeout"
495+ injection_scanner = "timeout"
496+ stage_results ["injection" ] = "deny"
497+ agt_mcp_denied = True
471498 except Exception : # nosec B110
472499 pass
473500
474- s4 = _stage4_injection_detection (
475- response_text ,
476- self ._injection_patterns ,
477- _agt_detector = self ._agt_injection_detector ,
478- )
501+ # INJECT-002: wrap AGT PromptInjectionDetector with the same timeout bound.
502+ def _run_s4 () -> StageResult :
503+ return _stage4_injection_detection (
504+ response_text ,
505+ self ._injection_patterns ,
506+ _agt_detector = self ._agt_injection_detector ,
507+ )
508+
509+ try :
510+ with concurrent .futures .ThreadPoolExecutor (max_workers = 1 ) as ex :
511+ s4 = ex .submit (_run_s4 ).result (timeout = self ._scanner_timeout )
512+ except concurrent .futures .TimeoutError :
513+ s4 = StageResult (
514+ stage = "injection" ,
515+ decision = "deny" ,
516+ reason = f"AGT PromptInjectionDetector timed out after { self ._scanner_timeout } s" ,
517+ injection_pattern = "detector_timeout" ,
518+ injection_scanner = "timeout" ,
519+ )
520+
479521 # POLICY-006: only overwrite injection decision if regex/AGT detector found a new deny,
480522 # or if the stage had not yet been set to deny by the MCPResponseScanner above.
481523 if s4 .decision == "deny" or not agt_mcp_denied :
482524 stage_results ["injection" ] = s4 .decision
483525 if s4 .decision == "deny" :
484526 deny_reasons .append (s4 .reason or "injection detected" )
485527 injection_pattern = s4 .injection_pattern
528+ if not injection_scanner :
529+ injection_scanner = s4 .injection_scanner
530+ injection_score = s4 .injection_score
486531
487532 final = "deny" if deny_reasons else "allow"
488533
@@ -508,4 +553,6 @@ def run(
508553 stage_results = stage_results ,
509554 response_payload_hash = response_payload_hash ,
510555 modified_response = modified_response ,
556+ injection_scanner = injection_scanner ,
557+ injection_score = injection_score ,
511558 )
0 commit comments