This commit is contained in:
lzhan011 2026-09-23 14:46:27 +00:00 • committed by GitHub
commit b635d92b7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 189 additions and 5 deletions

View file

@ -16,7 +16,7 @@ restriction intact.
import ast
import operator
from collections.abc import Callable, Mapping
from collections.abc import AsyncIterable, AsyncIterator, Callable, Mapping
from types import CodeType
from typing import Final
@ -31,6 +31,7 @@ from RestrictedPython.Eval import default_guarded_getitem, default_guarded_getit
from RestrictedPython.Guards import (
full_write_guard,
guarded_iter_unpack_sequence,
guarded_unpack_sequence,
safer_getattr,
)
@ -44,23 +45,63 @@ class AsyncAwareTransformer(RestrictingNodeTransformer):
has the same ``_fields`` as ``FunctionDef`` and the same security
semantics, so we delegate to ``visit_FunctionDef`` — name check, argument
check, print-scope wrapping, and any future additions to that method are
inherited automatically. ``AsyncFor``/``AsyncWith``/``Await`` delegate to
``node_contents_visit`` so their children still get transformed.
inherited automatically. ``AsyncWith`` gets the same treatment for the same
reason: ``node_contents_visit`` only recurses into children, so routing it
there left ``async with x as (a, b)`` without the unpack guard that
``with x as (a, b)`` gets. ``AsyncFor`` likewise delegates to ``visit_For``,
which is what wraps the loop iterator in ``_getiter_``. ``Await`` has no
synchronous counterpart and only wraps an expression, so it stays on
``node_contents_visit``.
"""
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.AST:
return self.visit_FunctionDef(node)
def visit_AsyncFor(self, node: ast.AsyncFor) -> ast.AST:
return self.node_contents_visit(node)
transformed: Final = self.visit_For(node)
_use_async_iter_unpack(transformed)
return transformed
def visit_AsyncWith(self, node: ast.AsyncWith) -> ast.AST:
return self.node_contents_visit(node)
return self.visit_With(node)
def visit_Await(self, node: ast.Await) -> ast.AST:
return self.node_contents_visit(node)
_ITER_UNPACK_NAME: Final = "_iter_unpack_sequence_"
_ASYNC_ITER_UNPACK_NAME: Final = "_aiter_unpack_sequence_"
def _use_async_iter_unpack(node: ast.AST) -> None:
"""Point a transformed ``async for`` at the async unpack guard.
``visit_For`` rewrites ``for a, b in x`` into
``for (a, b) in _iter_unpack_sequence_(x, spec, _getiter_)``. That helper is
a plain generator, which ``async for`` cannot consume, so the async form
needs the async-generator equivalent under its own name.
"""
iter_node: Final = getattr(node, "iter", None)
if (
isinstance(iter_node, ast.Call)
and isinstance(iter_node.func, ast.Name)
and iter_node.func.id == _ITER_UNPACK_NAME
):
iter_node.func.id = _ASYNC_ITER_UNPACK_NAME
async def _aiter_unpack_sequence_(
it: object, spec: object, _getiter_: Callable[[object], AsyncIterable[object]]
) -> AsyncIterator[object]:
"""``guarded_iter_unpack_sequence`` for ``async for`` targets.
Same contract as the RestrictedPython helper — guard the iteration, then
guard each element's sequence unpacking — over an async iterator.
"""
async for ob in _getiter_(it):
yield guarded_unpack_sequence(ob, spec, _getiter_)
_INPLACE_OPS: Final[Mapping[str, Callable[[object, object], object]]] = {
"+=": operator.iadd,
"-=": operator.isub,
@ -115,6 +156,13 @@ def build_sandbox_globals() -> dict[str, object]:
"_getitem_": default_guarded_getitem,
"_getiter_": default_guarded_getiter,
"_iter_unpack_sequence_": guarded_iter_unpack_sequence,
# RestrictedPython emits _unpack_sequence_ for every tuple-unpacking
# target that is not a for-loop target — ``a, b = pair``,
# ``a, *rest = seq``, ``with x as (a, b)`` — and, like _inplacevar_,
# ships no default. Without it those statements compile and then raise
# NameError the first time the guardrail runs.
"_unpack_sequence_": guarded_unpack_sequence,
_ASYNC_ITER_UNPACK_NAME: _aiter_unpack_sequence_,
"_write_": full_write_guard,
"_inplacevar_": _inplacevar_,
}

View file

@ -363,3 +363,139 @@ def test_augmented_assignment_works():
def test_missing_apply_guardrail_raises():
with pytest.raises(CustomCodeCompilationError, match="apply_guardrail"):
_compile("x = 1\n")
@pytest.mark.parametrize(
("body", "expected"),
[
# Every one of these compiles fine and then raises
# "NameError: name '_unpack_sequence_' is not defined" at call time when
# the guard is missing from the sandbox globals.
(" a, b = inputs['pair']\n return a + b\n", 3),
(" a, (b, c) = inputs['nested']\n return a + b + c\n", 6),
(" a, *rest = inputs['seq']\n return rest\n", [2, 3]),
(" with inputs['ctx'] as (a, b):\n return a + b\n", 15),
],
)
def test_tuple_unpacking_runs(body: str, expected):
"""Ordinary tuple unpacking must work inside a guardrail, not NameError."""
class _Ctx:
def __enter__(self):
return (7, 8)
def __exit__(self, *args):
return False
guardrail = _compile("def apply_guardrail(inputs, request_data, input_type):\n" + body)
fn = guardrail._compiled_function
assert fn is not None
inputs = {"pair": (1, 2), "nested": (1, (2, 3)), "seq": [1, 2, 3], "ctx": _Ctx()}
assert fn(inputs, {}, "request") == expected
def _guard_names(source: str) -> set[str]:
"""Names of the RestrictedPython guards the compiled bytecode calls."""
import types
from litellm.proxy.guardrails.guardrail_hooks.custom_code.sandbox import (
compile_sandboxed,
)
found: set[str] = set()
stack = [compile_sandboxed(source)]
while stack:
code = stack.pop()
found |= {n for n in code.co_names + code.co_varnames if n.startswith("_") and n.endswith("_")}
stack += [c for c in code.co_consts if isinstance(c, types.CodeType)]
return found
def test_async_with_gets_the_same_guards_as_with():
"""`async with` is the async spelling of `with`; it must not enforce less."""
sync_guards = _guard_names("def f(x):\n with x as (a, b):\n pass\n")
async_guards = _guard_names("async def f(x):\n async with x as (a, b):\n pass\n")
assert "_unpack_sequence_" in sync_guards, "precondition: `with` unpacking is guarded"
assert sync_guards <= async_guards
@pytest.mark.asyncio
async def test_async_with_still_executes():
"""Guarding `async with` must not break it."""
from litellm.proxy.guardrails.guardrail_hooks.custom_code.sandbox import (
build_sandbox_globals,
compile_sandboxed,
)
class _ACtx:
def __init__(self, value):
self.value = value
async def __aenter__(self):
return self.value
async def __aexit__(self, *args):
return False
sandbox_globals = build_sandbox_globals()
exec( # noqa: S102
compile_sandboxed(
"async def f(ctx):\n async with ctx as (a, b):\n return a + b\n"
),
sandbox_globals,
)
assert await sandbox_globals["f"](_ACtx((5, 6))) == 11
def test_async_for_gets_the_same_guards_as_for():
"""`async for` is the async spelling of `for`; it must not enforce less."""
sync_guards = _guard_names("def f(x):\n for a in x:\n pass\n")
async_guards = _guard_names("async def f(x):\n async for a in x:\n pass\n")
assert "_getiter_" in sync_guards, "precondition: `for` iteration is guarded"
assert sync_guards <= async_guards
def test_async_for_unpacking_uses_the_async_unpack_guard():
"""Tuple targets are guarded too, via the async-iterable variant of the helper."""
guards = _guard_names("async def f(x):\n async for a, b in x:\n pass\n")
assert "_aiter_unpack_sequence_" in guards
# The sync generator would raise "requires an object with __aiter__".
assert "_iter_unpack_sequence_" not in guards
@pytest.mark.asyncio
@pytest.mark.parametrize(
("body", "items", "expected"),
[
(" async for i in src:\n out.append(i)\n", [1, 2, 3], [1, 2, 3]),
(" async for a, b in src:\n out.append(a + b)\n", [(1, 2), (3, 4)], [3, 7]),
],
)
async def test_async_for_still_executes(body: str, items: list, expected: list):
"""Guarding `async for` must not break it, with or without a tuple target."""
from litellm.proxy.guardrails.guardrail_hooks.custom_code.sandbox import (
build_sandbox_globals,
compile_sandboxed,
)
class _AIter:
def __init__(self, values):
self.values = list(values)
def __aiter__(self):
return self
async def __anext__(self):
if not self.values:
raise StopAsyncIteration
return self.values.pop(0)
sandbox_globals = build_sandbox_globals()
exec( # noqa: S102
compile_sandboxed("async def f(src):\n out = []\n" + body + " return out\n"),
sandbox_globals,
)
assert await sandbox_globals["f"](_AIter(items)) == expected