mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
fix(guardrails): preserve stdlib regex semantics in isolated workers
This commit is contained in:
parent
0eca7d7c5f
commit
a6493b5854
3 changed files with 137 additions and 54 deletions
|
|
@ -0,0 +1,42 @@
|
|||
"""Run stdlib regex in isolation so a caller can terminate pathological matches."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from contextlib import redirect_stdout
|
||||
from typing import Final, TextIO
|
||||
|
||||
|
||||
def _execute(operation: str, text: str, pattern: str, flags: int, replacement: str) -> object:
|
||||
match operation:
|
||||
case "search":
|
||||
return bool(re.search(pattern, text, flags))
|
||||
case "fullmatch":
|
||||
return bool(re.fullmatch(pattern, text, flags))
|
||||
case "sub":
|
||||
return re.sub(pattern, replacement, text, flags=flags)
|
||||
case "findall":
|
||||
return re.findall(pattern, text, flags)
|
||||
case _:
|
||||
raise ValueError("unknown regex operation")
|
||||
|
||||
|
||||
def _main(request: str, output: TextIO, errors: TextIO) -> int:
|
||||
payload: Final[object] = json.loads(request) # pyright: ignore[reportAny] # stdlib JSON is validated below
|
||||
match payload:
|
||||
case [str(operation), str(text), str(pattern), int(flags), str(replacement)]:
|
||||
pass
|
||||
case _:
|
||||
raise ValueError("invalid regex request")
|
||||
try:
|
||||
with redirect_stdout(errors):
|
||||
result: Final = _execute(operation, text, pattern, flags, replacement)
|
||||
except re.error as error:
|
||||
errors.write(str(error))
|
||||
return 65
|
||||
output.write(json.dumps(result))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main(sys.stdin.read(), sys.stdout, sys.stderr))
|
||||
|
|
@ -8,13 +8,15 @@ and provide safe, sandboxed functionality for common guardrail operations.
|
|||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Final, Literal
|
||||
from pathlib import Path
|
||||
from typing import Final, Literal, TypeVar
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import regex
|
||||
from pydantic import JsonValue
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
import litellm
|
||||
|
|
@ -113,24 +115,44 @@ def modify(
|
|||
# =============================================================================
|
||||
|
||||
|
||||
_REGEX_FLAGS: Final = (
|
||||
(re.ASCII, regex.ASCII),
|
||||
(re.IGNORECASE, regex.IGNORECASE),
|
||||
(re.LOCALE, regex.LOCALE),
|
||||
(re.UNICODE, regex.UNICODE),
|
||||
(re.MULTILINE, regex.MULTILINE),
|
||||
(re.DOTALL, regex.DOTALL),
|
||||
(re.VERBOSE, regex.VERBOSE),
|
||||
(re.TEMPLATE, regex.TEMPLATE),
|
||||
(re.DEBUG, regex.DEBUG),
|
||||
)
|
||||
_RegexResult: Final = TypeVar("_RegexResult")
|
||||
_REGEX_MATCH: Final = TypeAdapter(bool)
|
||||
_REGEX_TEXT: Final = TypeAdapter(str)
|
||||
_REGEX_MATCHES: Final[TypeAdapter[Sequence[str | tuple[str, ...]]]] = TypeAdapter(Sequence[str | tuple[str, ...]])
|
||||
_REGEX_WORKER: Final = str(Path(__file__).with_name("_regex_worker.py"))
|
||||
|
||||
|
||||
def _regex_flags(pattern: str, flags: int) -> int:
|
||||
validated: Final = re.compile(pattern, flags).flags
|
||||
return regex.VERSION0 | sum(
|
||||
int(safe_flag) for standard_flag, safe_flag in _REGEX_FLAGS if validated & standard_flag
|
||||
)
|
||||
def _regex_result(
|
||||
operation: Literal["search", "fullmatch", "sub", "findall"],
|
||||
text: str,
|
||||
pattern: str,
|
||||
flags: int,
|
||||
result_type: TypeAdapter[_RegexResult],
|
||||
replacement: str = "",
|
||||
) -> _RegexResult:
|
||||
remaining_execution_time()
|
||||
with subprocess.Popen(
|
||||
[sys.executable, "-I", "-S", _REGEX_WORKER],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
env={},
|
||||
) as worker:
|
||||
try:
|
||||
output, error = worker.communicate(
|
||||
json.dumps((operation, text, pattern, flags, replacement)), timeout=remaining_execution_time()
|
||||
)
|
||||
except BaseException: # noqa: BLE001 # sandbox interruption must also terminate and reap the child
|
||||
worker.kill()
|
||||
worker.communicate()
|
||||
raise
|
||||
if worker.returncode == 65:
|
||||
raise re.error(error)
|
||||
if worker.returncode:
|
||||
raise RuntimeError(f"regex worker failed ({worker.returncode}): {error}")
|
||||
return result_type.validate_json(output)
|
||||
|
||||
|
||||
def regex_match(text: str, pattern: str, flags: int = 0) -> bool:
|
||||
|
|
@ -146,14 +168,10 @@ def regex_match(text: str, pattern: str, flags: int = 0) -> bool:
|
|||
True if pattern matches, False otherwise
|
||||
"""
|
||||
try:
|
||||
return bool(
|
||||
regex.search(
|
||||
pattern, text, _regex_flags(pattern, flags), timeout=remaining_execution_time(), concurrent=True
|
||||
)
|
||||
)
|
||||
except TimeoutError:
|
||||
return _regex_result("search", text, pattern, flags, _REGEX_MATCH)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ExecutionInterrupted from None
|
||||
except (re.error, regex.error) as e:
|
||||
except re.error as e:
|
||||
verbose_proxy_logger.warning("Starlark regex_match error: %s", e)
|
||||
return False
|
||||
|
||||
|
|
@ -171,14 +189,10 @@ def regex_match_all(text: str, pattern: str, flags: int = 0) -> bool:
|
|||
True if pattern matches entire text, False otherwise
|
||||
"""
|
||||
try:
|
||||
return bool(
|
||||
regex.fullmatch(
|
||||
pattern, text, _regex_flags(pattern, flags), timeout=remaining_execution_time(), concurrent=True
|
||||
)
|
||||
)
|
||||
except TimeoutError:
|
||||
return _regex_result("fullmatch", text, pattern, flags, _REGEX_MATCH)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ExecutionInterrupted from None
|
||||
except (re.error, regex.error) as e:
|
||||
except re.error as e:
|
||||
verbose_proxy_logger.warning("Starlark regex_match_all error: %s", e)
|
||||
return False
|
||||
|
||||
|
|
@ -197,22 +211,15 @@ def regex_replace(text: str, pattern: str, replacement: str, flags: int = 0) ->
|
|||
The text with replacements applied
|
||||
"""
|
||||
try:
|
||||
return regex.sub(
|
||||
pattern,
|
||||
replacement,
|
||||
text,
|
||||
flags=_regex_flags(pattern, flags),
|
||||
timeout=remaining_execution_time(),
|
||||
concurrent=True,
|
||||
)
|
||||
except TimeoutError:
|
||||
return _regex_result("sub", text, pattern, flags, _REGEX_TEXT, replacement)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ExecutionInterrupted from None
|
||||
except (re.error, regex.error) as e:
|
||||
except re.error as e:
|
||||
verbose_proxy_logger.warning("Starlark regex_replace error: %s", e)
|
||||
return text
|
||||
|
||||
|
||||
def regex_find_all(text: str, pattern: str, flags: int = 0) -> list[str]:
|
||||
def regex_find_all(text: str, pattern: str, flags: int = 0) -> Sequence[str | tuple[str, ...]]:
|
||||
"""
|
||||
Find all occurrences of a pattern in text.
|
||||
|
||||
|
|
@ -225,12 +232,10 @@ def regex_find_all(text: str, pattern: str, flags: int = 0) -> list[str]:
|
|||
List of all matches
|
||||
"""
|
||||
try:
|
||||
return regex.findall(
|
||||
pattern, text, _regex_flags(pattern, flags), timeout=remaining_execution_time(), concurrent=True
|
||||
)
|
||||
except TimeoutError:
|
||||
return _regex_result("findall", text, pattern, flags, _REGEX_MATCHES)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ExecutionInterrupted from None
|
||||
except (re.error, regex.error) as e:
|
||||
except re.error as e:
|
||||
verbose_proxy_logger.warning("Starlark regex_find_all error: %s", e)
|
||||
return []
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from io import StringIO
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.guardrails.guardrail_hooks.custom_code._regex_worker import _main as regex_worker
|
||||
from litellm.proxy.guardrails.guardrail_hooks.custom_code.bounded_execution import (
|
||||
ExecutionTimeoutError,
|
||||
SandboxExit,
|
||||
|
|
@ -212,13 +215,10 @@ async def test_regex_near_match_does_not_hold_the_execution_timeout() -> None:
|
|||
execution_timeout=0.1,
|
||||
)
|
||||
started: Final = time.monotonic()
|
||||
try:
|
||||
result: Final = await guardrail.apply_guardrail(
|
||||
with pytest.raises(CustomCodeExecutionError, match=r"exceeded its 0\.1s execution timeout"):
|
||||
await guardrail.apply_guardrail(
|
||||
inputs={"texts": ["a" * 26 + "!"]}, request_data={"model": "m"}, input_type="request"
|
||||
)
|
||||
assert result == {"texts": ["a" * 26 + "!"]}
|
||||
except CustomCodeExecutionError as error:
|
||||
assert "exceeded its 0.1s execution timeout" in str(error)
|
||||
await asyncio.to_thread(_join_worker_threads)
|
||||
assert time.monotonic() - started < 0.8
|
||||
assert _worker_threads() == []
|
||||
|
|
@ -263,6 +263,13 @@ async def test_regex_deadline_cannot_be_swallowed_as_a_failed_match(operation: s
|
|||
[
|
||||
("café", r"\w+", re.ASCII),
|
||||
("café", r"\w+", re.UNICODE),
|
||||
("123-45-6789\u200d", r"\b\d{3}-\d{2}-\d{4}\b", 0),
|
||||
("123-45-6789\u200c", r"\b\d{3}-\d{2}-\d{4}\b", 0),
|
||||
("a\u0301", r"\w+", 0),
|
||||
("²", r"\w+", 0),
|
||||
("\u001c", r"\s+", 0),
|
||||
("a\u200d", r"a\b\W", 0),
|
||||
("²", r"[^\W]+", 0),
|
||||
("HELLO", "hello", re.IGNORECASE),
|
||||
("first\nsecond", "^second$", re.MULTILINE),
|
||||
("first\nsecond", ".+", re.DOTALL),
|
||||
|
|
@ -289,3 +296,32 @@ def test_regex_primitives_preserve_standard_matching_results(text: str, pattern:
|
|||
expected_replacement,
|
||||
expected_matches,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("operation", "expected"),
|
||||
[("search", True), ("fullmatch", False), ("sub", "[REDACTED]\u200d"), ("findall", ["123-45-6789"])],
|
||||
)
|
||||
def test_regex_worker_protocol_preserves_unicode_and_separates_debug_output(operation: str, expected: object) -> None:
|
||||
output: Final = StringIO()
|
||||
errors: Final = StringIO()
|
||||
request: Final = json.dumps((operation, "123-45-6789\u200d", r"\b\d{3}-\d{2}-\d{4}\b", int(re.DEBUG), "[REDACTED]"))
|
||||
|
||||
assert regex_worker(request, output, errors) == 0
|
||||
assert json.loads(output.getvalue()) == expected
|
||||
assert errors.getvalue()
|
||||
|
||||
|
||||
def test_regex_worker_reports_invalid_patterns_without_a_success_result() -> None:
|
||||
output: Final = StringIO()
|
||||
errors: Final = StringIO()
|
||||
|
||||
assert regex_worker(json.dumps(("search", "hello", "(", 0, "")), output, errors) == 65
|
||||
assert output.getvalue() == ""
|
||||
assert errors.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", ["[]", '["unknown", "text", "pattern", 0, ""]'])
|
||||
def test_regex_worker_rejects_invalid_requests_instead_of_returning_false(payload: str) -> None:
|
||||
with pytest.raises(ValueError, match=r"invalid regex request|unknown regex operation"):
|
||||
regex_worker(payload, StringIO(), StringIO())
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue