@@ -183,8 +183,53 @@ def check(
183183
184184 return self .pass_result (message = "All safety checks passed" )
185185
186- def _extract_content (self , response : Dict ) -> str :
187- """Extract text content from response."""
186+ _MAX_CONTENT_DEPTH = 12
187+ _KNOWN_CONTENT_KEYS = ("content" , "output" , "text" , "arguments" )
188+
189+ def _extract_content (self , response : Dict , _depth : int = 0 ) -> str :
190+ """Extract text content from response — recursively (#29).
191+
192+ Walks all string values at any nesting depth (bounded to prevent
193+ DoS on deeply-nested payloads) so the guard can see content inside
194+ the canonical OpenAI shape (choices[].message.content), Anthropic
195+ envelopes, and arbitrary nested structures.
196+ """
197+ parts = self ._known_content_parts (response )
198+
199+ if _depth < self ._MAX_CONTENT_DEPTH :
200+ for key , value in response .items ():
201+ if not self ._should_traverse (key , value ):
202+ continue
203+ parts .append (self ._nested_content (value , _depth + 1 ))
204+
205+ return " " .join (parts )
206+
207+ def _should_traverse (self , key : str , value : Any ) -> bool :
208+ """Decide whether a response entry still needs recursive scanning.
209+
210+ - Unknown keys holding strings ARE scanned (nested scalars must be
211+ checked for injection/PII — Greptile P1).
212+ - Known content keys had their string forms collected verbatim above;
213+ their container forms are traversed so nothing hides inside them.
214+ - output/arguments dicts were already stringified above — skipping
215+ avoids duplicate collection.
216+ """
217+ if isinstance (value , str ):
218+ # content/output/text strings were collected verbatim above;
219+ # a string under 'arguments' was NOT (only dicts are) and must
220+ # still be scanned for injection/PII.
221+ if key == "arguments" :
222+ return True
223+ return key not in self ._KNOWN_CONTENT_KEYS
224+ if isinstance (value , dict ):
225+ return key not in ("output" , "arguments" )
226+ if isinstance (value , list ):
227+ return True
228+ return False # other scalars carry no scannable text
229+
230+ @staticmethod
231+ def _known_content_parts (response : Dict ) -> List [str ]:
232+ """Collect strings from the well-known top-level content keys."""
188233 parts = []
189234
190235 if isinstance (response .get ("content" ), str ):
@@ -200,7 +245,23 @@ def _extract_content(self, response: Dict) -> str:
200245 if isinstance (response .get ("arguments" ), dict ):
201246 parts .append (str (response ["arguments" ]))
202247
203- return " " .join (parts )
248+ return parts
249+
250+ def _nested_content (self , value : Any , depth : int ) -> str :
251+ """Recursively collect strings from unrecognized nesting levels."""
252+ if depth > self ._MAX_CONTENT_DEPTH :
253+ return ""
254+ if isinstance (value , dict ):
255+ return self ._extract_content (value , depth )
256+ if isinstance (value , list ):
257+ # Increment depth for list children too — otherwise list-only
258+ # nesting never reaches _MAX_CONTENT_DEPTH and a deeply (or
259+ # cyclically) nested list recurses until RecursionError (T-Rex P1).
260+ collected = [self ._nested_content (item , depth + 1 ) for item in value ]
261+ return " " .join (collected )
262+ if isinstance (value , str ):
263+ return value
264+ return ""
204265
205266 def _check_pii (self , content : str ) -> List [str ]:
206267 """Check for PII in content."""
0 commit comments