fix(guardrails): extract nested-wildcard request_fields so the check runs

JsonPathExtractor.evaluate normalized an expression by replacing [*]
with .[*]. and splitting on dots, then recursed into wildcard items
passing the ALREADY-normalized remaining path. Re-running the replace on
that path injected an empty segment (content..[*].text), so every
expression with two array wildcards - e.g. messages[*].content[*].text,
the natural request_fields for chat-shaped pass-through payloads -
evaluated to nothing. extract_fields returned an empty string and the
pass-through handler skipped the guardrail entirely ('No text to check'),
sending the request upstream unchecked while still recording the
guardrail as applied.

The normalization now splits each dot segment by peeling trailing
wildcards one at a time, which is idempotent for the paths recursion
feeds back through, and adjacent wildcards keep working.

Fixes #46302
This commit is contained in:
OrangerXu 2026-10-14 06:27:12 +08:00
parent c581c85559
commit 9a99e317ec
2 changed files with 40 additions and 1 deletions

View file

@ -55,7 +55,26 @@ class JsonPathExtractor:
if not expr or not data:
return None
parts: Final = expr.replace("[*]", ".[*]").split(".")
# Normalize "key[*]" into "key" + "[*]" segments. Idempotent: the
# recursion below passes already-normalized paths (e.g.
# "content.[*].text") back through here, and re-running
# expr.replace("[*]", ".[*]") on those would inject empty segments
# ("content..[*].text") and silently drop every nested-wildcard
# expression, skipping the guardrail (#46302).
segments: list[str] = []
for segment in expr.split("."):
if segment == "[*]":
segments.append("[*]")
continue
# Peel trailing wildcards one at a time so adjacent wildcards
# ("a[*][*]") and already-normalized paths both split cleanly.
while segment.endswith("[*]"):
segments.append(segment[:-3])
segments.append("[*]")
segment = ""
if segment:
segments.append(segment)
parts: Final = segments
current: Any = data
for i, part in enumerate(parts):

View file

@ -155,6 +155,26 @@ class TestJsonPathExtractorEvaluate:
result = JsonPathExtractor.evaluate(data, "messages[*].content")
assert result == ["You are helpful", "Hello"]
def test_nested_array_wildcards(self):
"""Should extract values through two array wildcards.
Regression test for #46302: the recursive wildcard branch passes the
already-normalized remaining path (e.g. "content.[*].text") back
through evaluate, where the old replace-based normalization injected
an empty segment ("content..[*].text") and silently dropped every
nested-wildcard expression - so a pass-through guardrail whose only
request_fields entry is messages[*].content[*].text never saw any
text and skipped the check entirely.
"""
data = {
"messages": [
{"role": "user", "content": [{"type": "text", "text": "item1"}]},
{"role": "assistant", "content": [{"type": "text", "text": "item2"}]},
]
}
assert JsonPathExtractor.evaluate(data, "messages[*].content[*].text") == ["item1", "item2"]
assert JsonPathExtractor.extract_fields(data, ["messages[*].content[*].text"]) == "item1" + chr(10) + "item2"
def test_missing_key_returns_none(self):
"""Should return None for missing key."""
data = {"query": "test"}