mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-30 00:22:10 -04:00
fix(security): prevent ReDoS in XML and args tool-call parsers (#4941)
* fix(security): prevent ReDoS in XML and args tool-call parsers
Four py/polynomial-redos sinks in tool_parsing.py ran lazy/greedy regexes over
untrusted model output (tool-call markup is attacker-influenced via prompt
injection). When the closing delimiter was absent, each rescanned to
end-of-string from every opener -> O(n^2):
- args => { ... } in _parse_tool_call_block: greedy \{([\s\S]*)\} restarted
from every `args:{` opener. Now finds the opener once and takes through the
last `}` (rfind) — equivalent capture, O(n).
- _XML_INVOKE_RE: lazy <invoke ...>([\s\S]*?)</invoke>. Now _iter_xml_invoke
pairs each opener with the first reachable </invoke> and stops when none is.
- _XML_DIRECT_TOOL_RE and the <tag>([\s\S]*?)</\1> param scan in
_parse_tool_code_block: lazy backreference patterns. Now _iter_backref_blocks
pairs each opener with the nearest matching closer and memoizes tag names
with no remaining closer, so an opener flood stays O(n).
All four are output-equivalent to the originals on well-formed tool-call markup;
the lazy patterns remain defined (still re-exported via agent_tools) but no
longer drive a finditer over untrusted text. Adds tests/test_redos_xml_tool_parsers.py
pinning correctness and bounding the opener-flood inputs (old paths took 4-15s).
* fix(security): harden invoke-parameter and distinct-name tag scans
Forward-only the two residual ReDoS paths in the XML/tool parsers that the
outer-delimiter fix left quadratic:
- _parse_xml_invoke parsed <parameter> with _XML_PARAM_RE.finditer, so a
closed <invoke> body full of unclosed <parameter> openers rescanned the
body from every opener (O(n^2), ~11s at 8k openers). Now scans forward-only
via _iter_named_blocks, factored out of _iter_xml_invoke.
- _iter_backref_blocks only memoized repeated missing tag names; a flood of
distinct unclosed names searched the suffix once per name (O(n^2)). It now
indexes every closer by name in one linear pass and binary-searches per
opener (O(n log n)). Covers the direct and tool_code backref scans.
Output-equivalent to the prior scanners (200k randomized trials match the
memoized version for both the direct ci=True and tool_code ci=False configs).
Adds regressions for the closed-invoke parameter flood and the distinct-name
floods (45k openers now run in ~0.05s, were 5-6s).
This commit is contained in:
+118
-28
@@ -6,6 +6,7 @@ Supports fenced code blocks, [TOOL_CALL] blocks, and XML-style <invoke> blocks.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
|
import bisect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
@@ -70,6 +71,27 @@ _XML_DIRECT_TOOL_RE = re.compile(
|
|||||||
r"<\s*([A-Za-z_][\w-]*)\s*>([\s\S]*?)</\s*\1\s*>",
|
r"<\s*([A-Za-z_][\w-]*)\s*>([\s\S]*?)</\s*\1\s*>",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
# Forward-only delimiters for the lazy XML patterns above, so untrusted "many
|
||||||
|
# openers, no closer" model output can't drive finditer's O(n^2) lazy rescan
|
||||||
|
# (CodeQL py/polynomial-redos). Consumed by _iter_xml_invoke / _iter_xml_direct.
|
||||||
|
_XML_INVOKE_OPEN_RE = re.compile(r'<invoke\s+name=["\'](\w+)["\']>\s*', re.IGNORECASE)
|
||||||
|
_XML_INVOKE_CLOSE_RE = re.compile(r'</invoke>', re.IGNORECASE)
|
||||||
|
_XML_DIRECT_OPEN_RE = re.compile(r"<\s*([A-Za-z_][\w-]*)\s*>", re.IGNORECASE)
|
||||||
|
# Split <parameter ...>...</parameter> delimiters: the parameter scan inside an
|
||||||
|
# invoke body is forward-only too, so a closed invoke stuffed with unclosed
|
||||||
|
# parameter openers can't drive finditer's O(n^2) rescan. See _iter_named_blocks.
|
||||||
|
_XML_PARAM_OPEN_RE = re.compile(r'<parameter\s+name=["\'](\w+)["\']>', re.IGNORECASE)
|
||||||
|
_XML_PARAM_CLOSE_RE = re.compile(r'</parameter>', re.IGNORECASE)
|
||||||
|
# Closer tokens (any tag name) for the backref scanners, pre-indexed by name so a
|
||||||
|
# flood of distinct unclosed tag names stays near-linear. See _iter_backref_blocks.
|
||||||
|
_XML_DIRECT_CLOSE_ANY_RE = re.compile(r"</\s*([A-Za-z_][\w-]*)\s*>", re.IGNORECASE)
|
||||||
|
# `args => { ... }` opener (its closer is the last `}`, found with rfind) and the
|
||||||
|
# `<tag>` opener for tool_code XML params — both split out of greedy/backref
|
||||||
|
# patterns that finditer would otherwise rescan from every opener. See
|
||||||
|
# _parse_tool_call_block / _parse_tool_code_block.
|
||||||
|
_ARGS_BRACE_OPEN_RE = re.compile(r'args\s*(?:=>|:|=)\s*\{')
|
||||||
|
_TOOL_CODE_PARAM_OPEN_RE = re.compile(r"<(\w+)>")
|
||||||
|
_TOOL_CODE_PARAM_CLOSE_ANY_RE = re.compile(r"</(\w+)>")
|
||||||
|
|
||||||
# Pattern 3b: StepFun Step-3.x native tool-call tokens. The tokenizer defines:
|
# Pattern 3b: StepFun Step-3.x native tool-call tokens. The tokenizer defines:
|
||||||
# <|tool▁calls▁begin|> ... <|tool▁calls▁end|>
|
# <|tool▁calls▁begin|> ... <|tool▁calls▁end|>
|
||||||
@@ -507,11 +529,15 @@ def _parse_tool_call_block(raw: str) -> Optional[ToolBlock]:
|
|||||||
if cmd_match:
|
if cmd_match:
|
||||||
content = cmd_match.group(1)
|
content = cmd_match.group(1)
|
||||||
|
|
||||||
# Pattern: args => {content} — extract everything inside the nested braces
|
# Pattern: args => {content} — extract everything inside the nested braces.
|
||||||
|
# Find the opener, then take through the LAST `}` (rfind). Equivalent to the
|
||||||
|
# greedy `\{([\s\S]*)\}` capture, but the bounded opener + rfind avoids
|
||||||
|
# finditer rescanning from every `args:{` opener (CodeQL py/polynomial-redos).
|
||||||
if not content:
|
if not content:
|
||||||
args_match = re.search(r'args\s*(?:=>|:|=)\s*\{([\s\S]*)\}', raw, re.DOTALL)
|
am = _ARGS_BRACE_OPEN_RE.search(raw)
|
||||||
if args_match:
|
close = raw.rfind('}')
|
||||||
inner = args_match.group(1).strip()
|
if am and close >= am.end():
|
||||||
|
inner = raw[am.end():close].strip()
|
||||||
# Strip quotes and key prefixes
|
# Strip quotes and key prefixes
|
||||||
inner = re.sub(r'^--?\w+\s+', '', inner)
|
inner = re.sub(r'^--?\w+\s+', '', inner)
|
||||||
inner = inner.strip('\'"')
|
inner = inner.strip('\'"')
|
||||||
@@ -539,8 +565,8 @@ def _parse_tool_call_block(raw: str) -> Optional[ToolBlock]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _parse_xml_invoke(inv_match) -> Optional[ToolBlock]:
|
def _parse_xml_invoke(name, body) -> Optional[ToolBlock]:
|
||||||
"""Parse an <invoke name="tool"><parameter ...>...</parameter></invoke> match.
|
"""Parse an <invoke name="tool"><parameter ...>...</parameter></invoke> call.
|
||||||
|
|
||||||
Delegates content-shaping to function_call_to_tool_block — the SAME
|
Delegates content-shaping to function_call_to_tool_block — the SAME
|
||||||
converter used for native function calls — so the full tool set (every
|
converter used for native function calls — so the full tool set (every
|
||||||
@@ -555,17 +581,16 @@ def _parse_xml_invoke(inv_match) -> Optional[ToolBlock]:
|
|||||||
# (e.g. <invoke name="Bash">) and function_call_to_tool_block matches
|
# (e.g. <invoke name="Bash">) and function_call_to_tool_block matches
|
||||||
# case-sensitively against the lowercase _TOOL_NAME_MAP / TOOL_TAGS, so a
|
# case-sensitively against the lowercase _TOOL_NAME_MAP / TOOL_TAGS, so a
|
||||||
# raw capitalized name would be silently dropped.
|
# raw capitalized name would be silently dropped.
|
||||||
tool_name = inv_match.group(1).lower()
|
tool_name = name.lower()
|
||||||
body = inv_match.group(2)
|
|
||||||
params = {}
|
params = {}
|
||||||
for pm in _XML_PARAM_RE.finditer(body):
|
for pname, pval in _iter_named_blocks(body, _XML_PARAM_OPEN_RE, _XML_PARAM_CLOSE_RE):
|
||||||
params[pm.group(1)] = pm.group(2).strip()
|
params[pname] = pval.strip()
|
||||||
# Local import to avoid a circular import at module load.
|
# Local import to avoid a circular import at module load.
|
||||||
from src.tool_schemas import function_call_to_tool_block
|
from src.tool_schemas import function_call_to_tool_block
|
||||||
return function_call_to_tool_block(tool_name, json.dumps(params))
|
return function_call_to_tool_block(tool_name, json.dumps(params))
|
||||||
|
|
||||||
|
|
||||||
def _parse_xml_direct_tool(tool_match) -> Optional[ToolBlock]:
|
def _parse_xml_direct_tool(name, body) -> Optional[ToolBlock]:
|
||||||
"""Parse direct XML tool tags inside <tool_call>.
|
"""Parse direct XML tool tags inside <tool_call>.
|
||||||
|
|
||||||
Some local models emit:
|
Some local models emit:
|
||||||
@@ -575,13 +600,13 @@ def _parse_xml_direct_tool(tool_match) -> Optional[ToolBlock]:
|
|||||||
Keep this as an adapter to the canonical function-call converter so aliases
|
Keep this as an adapter to the canonical function-call converter so aliases
|
||||||
and per-tool argument formatting stay in one place.
|
and per-tool argument formatting stay in one place.
|
||||||
"""
|
"""
|
||||||
tool_name = tool_match.group(1).lower().replace("-", "_")
|
tool_name = name.lower().replace("-", "_")
|
||||||
if tool_name in {"invoke", "parameter", "tool_call", "function_call"}:
|
if tool_name in {"invoke", "parameter", "tool_call", "function_call"}:
|
||||||
return None
|
return None
|
||||||
mapped = _TOOL_NAME_MAP.get(tool_name) or (tool_name if tool_name in TOOL_TAGS else None)
|
mapped = _TOOL_NAME_MAP.get(tool_name) or (tool_name if tool_name in TOOL_TAGS else None)
|
||||||
if not mapped:
|
if not mapped:
|
||||||
return None
|
return None
|
||||||
body = tool_match.group(2).strip()
|
body = body.strip()
|
||||||
if not body:
|
if not body:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -716,10 +741,12 @@ def _parse_tool_code_block(raw: str) -> Optional[ToolBlock]:
|
|||||||
args_match = re.search(r"args\s*=>\s*['\"]?\s*([\s\S]*?)\s*['\"]?\s*$", raw, re.DOTALL)
|
args_match = re.search(r"args\s*=>\s*['\"]?\s*([\s\S]*?)\s*['\"]?\s*$", raw, re.DOTALL)
|
||||||
args_body = args_match.group(1).strip().strip("'\"") if args_match else ""
|
args_body = args_match.group(1).strip().strip("'\"") if args_match else ""
|
||||||
|
|
||||||
# Parse XML params inside args (e.g. <command>ls</command>)
|
# Parse XML params inside args (e.g. <command>ls</command>). Forward-only
|
||||||
|
# backref scan so a `<x><x>...` opener flood can't drive the O(n^2) lazy
|
||||||
|
# rescan (CodeQL py/polynomial-redos); see _iter_backref_blocks.
|
||||||
xml_params = {}
|
xml_params = {}
|
||||||
for pm in re.finditer(r"<(\w+)>([\s\S]*?)</\1>", args_body):
|
for pname, pval in _iter_backref_blocks(args_body, _TOOL_CODE_PARAM_OPEN_RE, _TOOL_CODE_PARAM_CLOSE_ANY_RE):
|
||||||
xml_params[pm.group(1)] = pm.group(2).strip()
|
xml_params[pname] = pval.strip()
|
||||||
|
|
||||||
# When the model gave structured params, hand them to the canonical
|
# When the model gave structured params, hand them to the canonical
|
||||||
# converter (same as native calls + <invoke>) so the full tool set and
|
# converter (same as native calls + <invoke>) so the full tool set and
|
||||||
@@ -800,6 +827,69 @@ def _strip_delimited(text: str, open_re, close_re) -> str:
|
|||||||
return "".join(out)
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_named_blocks(text, open_re, close_re):
|
||||||
|
"""Forward-only equivalent of ``open_re([\\s\\S]*?)close_re`` finditer where
|
||||||
|
open_re captures a name in group 1: yield ``(name, body)``, pairing each
|
||||||
|
opener with the first ``close_re`` after it. O(n) once no closer is reachable
|
||||||
|
from an opener, no later opener has one either (see _iter_delimited), so
|
||||||
|
untrusted opener floods can't drive the lazy O(n^2) rescan."""
|
||||||
|
pos = 0
|
||||||
|
while True:
|
||||||
|
om = open_re.search(text, pos)
|
||||||
|
if om is None:
|
||||||
|
return
|
||||||
|
cm = close_re.search(text, om.end())
|
||||||
|
if cm is None:
|
||||||
|
return
|
||||||
|
yield om.group(1), text[om.end():cm.start()]
|
||||||
|
pos = cm.end()
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_xml_invoke(text):
|
||||||
|
"""Forward-only ``<invoke name="..">...</invoke>`` scan (see _iter_named_blocks)."""
|
||||||
|
return _iter_named_blocks(text, _XML_INVOKE_OPEN_RE, _XML_INVOKE_CLOSE_RE)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_backref_blocks(text, open_re, close_any_re, ci=False):
|
||||||
|
"""Forward-only equivalent of an ``<tag>([\\s\\S]*?)</tag>`` backreference
|
||||||
|
finditer (same-name open/close): yield ``(name, body)``, pairing each opener
|
||||||
|
with the nearest following matching closer and skipping an opener whose
|
||||||
|
closer is unreachable.
|
||||||
|
|
||||||
|
Every closer is indexed by tag name in one linear pass, then each opener
|
||||||
|
binary-searches its own name's closer positions. A flood of distinct unclosed
|
||||||
|
tag names therefore stays O(n log n) rather than the lazy backref's O(n^2)
|
||||||
|
suffix rescan (CodeQL py/polynomial-redos); per-name memoization alone left
|
||||||
|
that distinct-name case quadratic. ``close_any_re`` matches ANY closer and
|
||||||
|
captures its tag name in group 1; ``ci`` lowercases names for matching, since
|
||||||
|
the original backref closer is case-insensitive under re.IGNORECASE."""
|
||||||
|
norm = (lambda s: s.lower()) if ci else (lambda s: s)
|
||||||
|
closer_starts = {}
|
||||||
|
closer_ends = {}
|
||||||
|
for cm in close_any_re.finditer(text):
|
||||||
|
k = norm(cm.group(1))
|
||||||
|
closer_starts.setdefault(k, []).append(cm.start())
|
||||||
|
closer_ends.setdefault(k, []).append(cm.end())
|
||||||
|
om = open_re.search(text)
|
||||||
|
while om is not None:
|
||||||
|
name = om.group(1)
|
||||||
|
k = norm(name)
|
||||||
|
resume = om.end()
|
||||||
|
starts = closer_starts.get(k)
|
||||||
|
if starts:
|
||||||
|
i = bisect.bisect_left(starts, om.end())
|
||||||
|
if i < len(starts):
|
||||||
|
yield name, text[om.end():starts[i]]
|
||||||
|
resume = closer_ends[k][i]
|
||||||
|
om = open_re.search(text, resume)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_xml_direct(text):
|
||||||
|
"""Forward-only equivalent of ``_XML_DIRECT_TOOL_RE.finditer`` (see
|
||||||
|
_iter_backref_blocks)."""
|
||||||
|
return _iter_backref_blocks(text, _XML_DIRECT_OPEN_RE, _XML_DIRECT_CLOSE_ANY_RE, ci=True)
|
||||||
|
|
||||||
|
|
||||||
def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
||||||
"""Extract executable tool blocks from LLM response text.
|
"""Extract executable tool blocks from LLM response text.
|
||||||
|
|
||||||
@@ -840,8 +930,8 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
|||||||
# If a code block's content is an <invoke> XML call (some models wrap
|
# If a code block's content is an <invoke> XML call (some models wrap
|
||||||
# tool calls in ```python or ```xml fences), parse the invoke instead.
|
# tool calls in ```python or ```xml fences), parse the invoke instead.
|
||||||
if '<invoke' in content:
|
if '<invoke' in content:
|
||||||
for inv in _XML_INVOKE_RE.finditer(content):
|
for inv_name, inv_body in _iter_xml_invoke(content):
|
||||||
block = _parse_xml_invoke(inv)
|
block = _parse_xml_invoke(inv_name, inv_body)
|
||||||
if block:
|
if block:
|
||||||
blocks.append(block)
|
blocks.append(block)
|
||||||
# This fenced block is <invoke> markup, not literal code. Whether or
|
# This fenced block is <invoke> markup, not literal code. Whether or
|
||||||
@@ -882,13 +972,13 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
|||||||
text, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE
|
text, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE
|
||||||
):
|
):
|
||||||
body = text[inner_start:inner_end]
|
body = text[inner_start:inner_end]
|
||||||
for inv in _XML_INVOKE_RE.finditer(body):
|
for inv_name, inv_body in _iter_xml_invoke(body):
|
||||||
block = _parse_xml_invoke(inv)
|
block = _parse_xml_invoke(inv_name, inv_body)
|
||||||
if block:
|
if block:
|
||||||
blocks.append(block)
|
blocks.append(block)
|
||||||
if not blocks:
|
if not blocks:
|
||||||
for direct in _XML_DIRECT_TOOL_RE.finditer(body):
|
for d_name, d_body in _iter_xml_direct(body):
|
||||||
block = _parse_xml_direct_tool(direct)
|
block = _parse_xml_direct_tool(d_name, d_body)
|
||||||
if block:
|
if block:
|
||||||
blocks.append(block)
|
blocks.append(block)
|
||||||
# Some local models stream an opening <tool_call> wrapper and a
|
# Some local models stream an opening <tool_call> wrapper and a
|
||||||
@@ -896,20 +986,20 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
|||||||
if not blocks:
|
if not blocks:
|
||||||
for m in _XML_OPEN_TOOL_CALL_RE.finditer(text):
|
for m in _XML_OPEN_TOOL_CALL_RE.finditer(text):
|
||||||
body = m.group(1)
|
body = m.group(1)
|
||||||
for inv in _XML_INVOKE_RE.finditer(body):
|
for inv_name, inv_body in _iter_xml_invoke(body):
|
||||||
block = _parse_xml_invoke(inv)
|
block = _parse_xml_invoke(inv_name, inv_body)
|
||||||
if block:
|
if block:
|
||||||
blocks.append(block)
|
blocks.append(block)
|
||||||
if blocks:
|
if blocks:
|
||||||
break
|
break
|
||||||
for direct in _XML_DIRECT_TOOL_RE.finditer(body):
|
for d_name, d_body in _iter_xml_direct(body):
|
||||||
block = _parse_xml_direct_tool(direct)
|
block = _parse_xml_direct_tool(d_name, d_body)
|
||||||
if block:
|
if block:
|
||||||
blocks.append(block)
|
blocks.append(block)
|
||||||
# Try bare <invoke> without wrapper
|
# Try bare <invoke> without wrapper
|
||||||
if not blocks:
|
if not blocks:
|
||||||
for inv in _XML_INVOKE_RE.finditer(text):
|
for inv_name, inv_body in _iter_xml_invoke(text):
|
||||||
block = _parse_xml_invoke(inv)
|
block = _parse_xml_invoke(inv_name, inv_body)
|
||||||
if block:
|
if block:
|
||||||
blocks.append(block)
|
blocks.append(block)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Regression tests for the remaining ReDoS sinks in tool_parsing.py.
|
||||||
|
|
||||||
|
A previous fix (test_redos_llm_parsers.py) hardened the delimiter-bounded
|
||||||
|
[TOOL_CALL]/<tool_call>/<tool_code> scanners but explicitly left four patterns
|
||||||
|
that CodeQL (py/polynomial-redos) flagged on the next rescan:
|
||||||
|
|
||||||
|
* `args => { ... }` in `_parse_tool_call_block` — greedy `\\{([\\s\\S]*)\\}`
|
||||||
|
that `re.search` restarts from every `args:{` opener -> O(n^2).
|
||||||
|
* `_XML_INVOKE_RE` — lazy `<invoke ...>([\\s\\S]*?)</invoke>` that rescans to
|
||||||
|
end-of-string from every opener when no `</invoke>` follows.
|
||||||
|
* `_XML_DIRECT_TOOL_RE` and the `<tag>([\\s\\S]*?)</\\1>` param scan in
|
||||||
|
`_parse_tool_code_block` — lazy *backreference* patterns with the same
|
||||||
|
opener-flood blowup.
|
||||||
|
|
||||||
|
These run over untrusted model output (tool-call markup is attacker-influenced
|
||||||
|
via prompt injection), so each is now a forward-only scan. The tests pin:
|
||||||
|
* correctness is unchanged for legitimate tool-call markup, and
|
||||||
|
* pathological "many openers, no closer" inputs complete promptly.
|
||||||
|
|
||||||
|
The timing bound is loose (seconds) so it never flakes on a slow CI box; the
|
||||||
|
unguarded patterns took 2-15s on these inputs, so the margin is ~100x.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import src.agent_tools # noqa: F401 (break agent_tools<->tool_parsing import cycle)
|
||||||
|
from src.tool_parsing import (
|
||||||
|
parse_tool_blocks,
|
||||||
|
strip_tool_blocks,
|
||||||
|
_parse_tool_call_block,
|
||||||
|
_parse_tool_code_block,
|
||||||
|
)
|
||||||
|
|
||||||
|
_BUDGET_S = 4.0
|
||||||
|
|
||||||
|
|
||||||
|
def _timed(fn, *args):
|
||||||
|
start = time.perf_counter()
|
||||||
|
result = fn(*args)
|
||||||
|
return result, time.perf_counter() - start
|
||||||
|
|
||||||
|
|
||||||
|
# ── correctness is preserved ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_xml_invoke_call_still_parsed():
|
||||||
|
blocks = parse_tool_blocks(
|
||||||
|
'<tool_call><invoke name="bash"><parameter name="command">ls -la</parameter></invoke></tool_call>'
|
||||||
|
)
|
||||||
|
assert [(b.tool_type, b.content) for b in blocks] == [("bash", "ls -la")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_xml_direct_tool_still_parsed():
|
||||||
|
blocks = parse_tool_blocks('<tool_call><web_search>weather today</web_search></tool_call>')
|
||||||
|
assert [(b.tool_type, b.content) for b in blocks] == [("web_search", "weather today")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_xml_direct_tool_backref_is_case_insensitive():
|
||||||
|
# `</\\1>` matched case-insensitively under re.IGNORECASE; the forward-only
|
||||||
|
# scanner preserves that (mixed-case closer still pairs with its opener).
|
||||||
|
blocks = parse_tool_blocks('<tool_call><Web_Search>q</WEB_SEARCH></tool_call>')
|
||||||
|
assert [(b.tool_type, b.content) for b in blocks] == [("web_search", "q")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_code_xml_params_still_parsed():
|
||||||
|
blocks = parse_tool_blocks("<tool_code>{tool => 'bash', args => '<command>ls -la</command>'}</tool_code>")
|
||||||
|
assert [(b.tool_type, b.content) for b in blocks] == [("bash", "ls -la")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_xml_invoke_multiple_parameters_still_parsed():
|
||||||
|
# The invoke parameter scan is forward-only; a well-formed invoke with more
|
||||||
|
# than one <parameter> must still yield every name/value pair.
|
||||||
|
blocks = parse_tool_blocks(
|
||||||
|
'<tool_call><invoke name="web_search">'
|
||||||
|
'<parameter name="query">rust traits</parameter>'
|
||||||
|
'<parameter name="time_filter">week</parameter>'
|
||||||
|
'</invoke></tool_call>'
|
||||||
|
)
|
||||||
|
assert len(blocks) == 1
|
||||||
|
assert blocks[0].tool_type == "web_search"
|
||||||
|
assert '"query": "rust traits"' in blocks[0].content
|
||||||
|
assert '"time_filter": "week"' in blocks[0].content
|
||||||
|
|
||||||
|
|
||||||
|
def test_xml_direct_distinct_tag_names_still_parsed():
|
||||||
|
# Distinct sibling tags inside <tool_call> each pair with their own closer;
|
||||||
|
# the forward-only direct scan must keep matching after the first block.
|
||||||
|
blocks = parse_tool_blocks(
|
||||||
|
'<tool_call><web_search>weather</web_search><read_file>notes.txt</read_file></tool_call>'
|
||||||
|
)
|
||||||
|
assert [(b.tool_type, b.content) for b in blocks] == [
|
||||||
|
("web_search", "weather"),
|
||||||
|
("read_file", "notes.txt"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_call_args_brace_still_parsed():
|
||||||
|
blocks = parse_tool_blocks('[TOOL_CALL]{tool => "shell", args => {--command "ls"}}[/TOOL_CALL]')
|
||||||
|
assert [(b.tool_type, b.content) for b in blocks] == [("bash", "ls")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_args_brace_takes_through_last_close_brace():
|
||||||
|
# `\\{([\\s\\S]*)\\}` is greedy to the LAST `}`; the rfind-based rewrite must
|
||||||
|
# match that (keep the nested object intact, not stop at the first `}`).
|
||||||
|
block = _parse_tool_call_block('tool => "bash", args => {--command "echo {x} done"}')
|
||||||
|
assert block is not None and block.tool_type == "bash"
|
||||||
|
assert block.content == "echo {x} done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fenced_invoke_still_parsed():
|
||||||
|
blocks = parse_tool_blocks(
|
||||||
|
'```python\n<invoke name="bash"><parameter name="command">whoami</parameter></invoke>\n```'
|
||||||
|
)
|
||||||
|
assert [(b.tool_type, b.content) for b in blocks] == [("bash", "whoami")]
|
||||||
|
|
||||||
|
|
||||||
|
# ── pathological inputs no longer blow up ───────────────────────────────────
|
||||||
|
|
||||||
|
def test_args_brace_opener_flood_is_fast():
|
||||||
|
# Many `args:{` openers, no closing `}` — old greedy capture restarted from
|
||||||
|
# every opener (>10s); the bounded opener + rfind is O(n).
|
||||||
|
evil = "args:{{a" * 14000
|
||||||
|
block, dt = _timed(_parse_tool_call_block, evil)
|
||||||
|
assert dt < _BUDGET_S, f"_parse_tool_call_block took {dt:.2f}s"
|
||||||
|
assert block is None
|
||||||
|
# And through the public path, wrapped in a [TOOL_CALL] block.
|
||||||
|
_, dt2 = _timed(parse_tool_blocks, "[TOOL_CALL]{" + evil + "}[/TOOL_CALL]")
|
||||||
|
assert dt2 < _BUDGET_S, f"parse_tool_blocks took {dt2:.2f}s"
|
||||||
|
|
||||||
|
|
||||||
|
def test_xml_invoke_opener_flood_is_fast():
|
||||||
|
# Bare <invoke> opener flood, no </invoke> closer.
|
||||||
|
evil = ('<invoke name="x">' + "a" * 10) * 6000
|
||||||
|
blocks, dt = _timed(parse_tool_blocks, evil)
|
||||||
|
assert dt < _BUDGET_S, f"parse_tool_blocks took {dt:.2f}s"
|
||||||
|
assert blocks == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_xml_invoke_stale_closer_before_opener_flood_is_fast():
|
||||||
|
# A lone leading </invoke> satisfies a substring guard, but no opener after
|
||||||
|
# it has a reachable closer.
|
||||||
|
evil = "</invoke>" + ('<invoke name="x">' + "a" * 10) * 6000
|
||||||
|
_, dt = _timed(parse_tool_blocks, evil)
|
||||||
|
assert dt < _BUDGET_S, f"parse_tool_blocks took {dt:.2f}s"
|
||||||
|
|
||||||
|
|
||||||
|
def test_xml_direct_backref_opener_flood_is_fast():
|
||||||
|
# <tool_call> wrapper (no </tool_call>) routes into the open-wrapper path,
|
||||||
|
# which reaches the _XML_DIRECT_TOOL_RE backreference scan: a `<a><a>...`
|
||||||
|
# flood with no `</a>` closer.
|
||||||
|
evil = "<tool_call>" + "<a><a>b" * 6000
|
||||||
|
blocks, dt = _timed(parse_tool_blocks, evil)
|
||||||
|
assert dt < _BUDGET_S, f"parse_tool_blocks took {dt:.2f}s"
|
||||||
|
assert blocks == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_code_param_backref_flood_is_fast():
|
||||||
|
# `<x><x>...` param flood inside tool_code args, no `</x>` closer — exercises
|
||||||
|
# the `<tag>([\\s\\S]*?)</\\1>` backreference scan in _parse_tool_code_block.
|
||||||
|
args_flood = "tool => 'bash', args => " + "<x><x>a" * 6000
|
||||||
|
block, dt = _timed(_parse_tool_code_block, args_flood)
|
||||||
|
assert dt < _BUDGET_S, f"_parse_tool_code_block took {dt:.2f}s"
|
||||||
|
# Through the public path, inside a closed <tool_code> block.
|
||||||
|
_, dt2 = _timed(parse_tool_blocks, "<tool_code>{" + args_flood + "}</tool_code>")
|
||||||
|
assert dt2 < _BUDGET_S, f"parse_tool_blocks took {dt2:.2f}s"
|
||||||
|
|
||||||
|
|
||||||
|
def test_xml_invoke_closed_with_parameter_opener_flood_is_fast():
|
||||||
|
# A CLOSED <invoke> whose body is a flood of `<parameter name=..>` openers
|
||||||
|
# with no `</parameter>` closer: the invoke delimiter pairs fine, but the
|
||||||
|
# inner parameter scan must not rescan the body from every opener (O(n^2)).
|
||||||
|
evil = ('<tool_call><invoke name="bash">'
|
||||||
|
+ '<parameter name="x">' * 6000
|
||||||
|
+ '</invoke></tool_call>')
|
||||||
|
blocks, dt = _timed(parse_tool_blocks, evil)
|
||||||
|
assert dt < _BUDGET_S, f"parse_tool_blocks took {dt:.2f}s"
|
||||||
|
# No `</parameter>` ever closes, so no params are captured.
|
||||||
|
assert len(blocks) == 1 and blocks[0].tool_type == "bash"
|
||||||
|
|
||||||
|
|
||||||
|
def test_xml_direct_distinct_name_opener_flood_is_fast():
|
||||||
|
# Distinct unclosed tag names (`<t0><t1>...`) defeat per-name memoization;
|
||||||
|
# the scan must still stay near-linear instead of searching the suffix once
|
||||||
|
# per new name.
|
||||||
|
evil = "<tool_call>" + "".join(f"<t{i}>" for i in range(45000))
|
||||||
|
blocks, dt = _timed(parse_tool_blocks, evil)
|
||||||
|
assert dt < _BUDGET_S, f"parse_tool_blocks took {dt:.2f}s"
|
||||||
|
assert blocks == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_code_param_distinct_name_flood_is_fast():
|
||||||
|
# Same distinct-name flood inside tool_code args, reaching the param backref
|
||||||
|
# scan in _parse_tool_code_block.
|
||||||
|
args_flood = "tool => 'bash', args => " + "".join(f"<t{i}>" for i in range(45000))
|
||||||
|
_, dt = _timed(_parse_tool_code_block, args_flood)
|
||||||
|
assert dt < _BUDGET_S, f"_parse_tool_code_block took {dt:.2f}s"
|
||||||
Reference in New Issue
Block a user