Coverage for gco_mcp / mission / predicate.py: 100.00%
202 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Restricted AST evaluator for ``Criterion(kind="predicate")`` expressions.
3A Mission criterion of kind ``predicate`` carries a small Python expression
4that runs against an ``Observation`` dict. Operator-supplied source must be
5treated as untrusted: the same JSON that carries it travels across MCP, the
6CLI, and disk. We parse the expression once at session start (so the
7operator sees errors immediately, not on iteration N), validate it against
8a tight allowlist, and cache the AST on the criterion so every later
9evaluation reuses it without reparsing.
11The sandbox has two layers:
131. **Parse-time validation.** :func:`parse_predicate` parses the source in
14 ``eval`` mode and walks the tree with :class:`_PredicateValidator`. The
15 first disallowed construct raises :class:`PredicateRejected` and the
16 evaluator is never reached.
172. **Eval-time isolation.** :func:`evaluate_predicate` compiles the
18 already-validated AST and calls :func:`eval` with an empty
19 ``__builtins__`` plus an explicit safe-callable namespace. With
20 ``__builtins__`` cleared, even a tree that smuggled past the validator
21 could not look up ``__import__``, ``open``, ``compile``, etc.
23Allowed surface
24---------------
25Names: ``obs`` (the dict argument), and the read-only callables ``len``,
26``min``, ``max``, ``sum``, ``abs``, ``any``, ``all``, ``sorted``, plus
27the four type coercions ``str``, ``int``, ``float``, ``bool``.
29Operators: arithmetic (``+ - * / // % ** @``), unary (``+ - not ~``),
30comparisons (``< <= > >= == != is is_not in not_in``), boolean
31(``and or``), and the ternary ``a if b else c``.
33Containers and collections: ``List``, ``Tuple``, ``Dict``, ``Set``, plus
34``ListComp``, ``SetComp``, ``DictComp``, ``GeneratorExp`` (their iteration
35targets must not shadow a name from the allowlist).
37Calls: bare-name calls to one of the twelve stdlib callables above, OR
38read-only method calls — ``.get(key[, default])``, ``.keys()``,
39``.values()``, ``.items()``, ``.lower()``, ``.upper()``, ``.strip()``,
40and ``.startswith(prefix[, start[, end]])``. A method receiver may be any
41expression that the predicate AST otherwise allows, including data names,
42subscripts, literals and container expressions, safe-call results, and
43comprehension-bound names. The receiver is recursively validated before the
44method call is accepted. Method calls outside the allowlist (``.update``,
45``.pop``, ``.count``, ``.append``, ...) are rejected with
46``call_target_method_not_allowed``.
48Attribute access: a bare attribute read is allowed only as ``obs.<attr>``
49(one level), and the attribute name itself must not start with ``__``.
50Bare chained walks and bare attributes on calls or subscripts are rejected.
51This restriction does not prohibit an allowlisted read-only method call: its
52receiver is validated as an ordinary allowed expression by ``visit_Call``.
53Predicates that need nested data should otherwise use subscripting.
55Subscripts: any ``value[...]`` chain whose ultimate base is an allowlisted
56name. Rejection happens automatically because every nested ``Name`` lookup
57is validated.
59f-strings: ``JoinedStr`` and ``FormattedValue`` recurse normally so any
60embedded name lookup re-enters this same allowlist check.
62Rejected outright
63-----------------
64``Import`` / ``ImportFrom`` (also unreachable in ``eval`` mode), ``Lambda``
65(it would let a predicate ship hidden code), the walrus ``NamedExpr``,
66``Yield`` / ``YieldFrom`` / ``Await`` and other async constructs, any
67identifier or string constant that starts with ``__``, and every
68``Name``/``Attribute``/``Call`` whose target is not on the allowlist.
69"""
71from __future__ import annotations
73import ast
74from typing import Any, Final, NoReturn
76# ---------------------------------------------------------------------------
77# Allowlists
78# ---------------------------------------------------------------------------
80_ALLOWED_CALLABLES: Final[frozenset[str]] = frozenset(
81 {"len", "min", "max", "sum", "abs", "any", "all", "sorted", "str", "int", "float", "bool"}
82)
83"""Builtin callables a predicate may invoke. Pure, side-effect-free.
85The eight stdlib aggregate / comparison helpers (``len``, ``min``,
86``max``, ``sum``, ``abs``, ``any``, ``all``, ``sorted``) plus four
87type coercions (``str``, ``int``, ``float``, ``bool``). The
88coercions are useful for normalising values before comparison —
89``str(r.get('count')) == '0'`` and ``bool(obs['errors'])`` are
90common idioms — and none of them can escape the eval-time sandbox
91(empty ``__builtins__``, no ``__import__`` / ``open`` / ``getattr``
92in scope) regardless of input.
93"""
95_ALLOWED_METHOD_CALLS: Final[frozenset[str]] = frozenset(
96 {"get", "keys", "values", "items", "lower", "upper", "strip", "startswith"}
97)
98"""Read-only methods a predicate may invoke on any value.
100Models trained on Python idioms gravitate toward ``r.get('_status')``,
101``r.items()``, ``str(...).lower()`` for case-insensitive substring
102search, and ``k.startswith('val_')`` for key classification. The eight
103methods listed here are all pure read-only accessors / transformations:
105* ``dict.get(key[, default])`` returns the value at ``key`` (or
106 ``default``); identical to subscript except it tolerates missing
107 keys without raising.
108* ``dict.keys()`` / ``dict.values()`` / ``dict.items()`` return views
109 that the comprehension protocol then iterates.
110* ``str.lower()`` / ``str.upper()`` return a new string with
111 case-folded contents; common in case-insensitive substring
112 search like ``'foo' in str(x).lower()``.
113* ``str.strip()`` returns a new string with leading and trailing
114 whitespace removed; common in normalising values before
115 comparison.
116* ``str.startswith()`` returns whether a string begins with a prefix;
117 models commonly use it to classify metric and event names.
119None of the eight can mutate state, escape ``__builtins__``, or reach a
120callable that we did not already opt into through the eval-time
121sandbox (``__builtins__`` is empty; ``eval`` / ``compile`` /
122``__import__`` / ``getattr`` / ``setattr`` / ``open`` are all
123unreachable). Allowing them lets the model write the natural
124expression ``any('inference' in str(r).lower() for r in obs['tool_results'])``
125instead of being forced into the more verbose subscript-only equivalent
126that the model rarely produces unprompted.
128Method-call gating still applies in two places:
1301. The attribute *name* must be in this set. ``r.update(...)``,
131 ``r.pop(...)``, ``r.setdefault(...)``, etc. raise
132 ``call_target_method_not_allowed`` even though they would otherwise
133 parse as ``Attribute -> Call``.
1342. The receiver expression is recursively validated against the same AST
135 allowlist as the rest of the predicate. Literals, containers, safe-call
136 results, subscripts, data names, and comprehension targets are therefore
137 valid receivers when their expression is otherwise allowed. This does not
138 widen the method set: ``[1, 2].count(1)`` is rejected because ``count`` is
139 not allowlisted, while ``" x ".strip()`` and ``str(value).lower()`` use
140 allowlisted methods on valid receiver expressions.
141"""
143_ALLOWED_DATA_NAMES: Final[frozenset[str]] = frozenset({"obs"})
144"""Top-level data names the predicate may read."""
146_ALLOWED_NAMES: Final[frozenset[str]] = _ALLOWED_DATA_NAMES | _ALLOWED_CALLABLES
147"""Every globally-allowed identifier the predicate may reference."""
149_ALLOWED_BIN_OPS: Final[tuple[type[ast.operator], ...]] = (
150 ast.Add,
151 ast.Sub,
152 ast.Mult,
153 ast.Div,
154 ast.FloorDiv,
155 ast.Mod,
156 ast.Pow,
157 ast.MatMult,
158)
160_ALLOWED_UNARY_OPS: Final[tuple[type[ast.unaryop], ...]] = (
161 ast.UAdd,
162 ast.USub,
163 ast.Not,
164 ast.Invert,
165)
167_ALLOWED_COMPARE_OPS: Final[tuple[type[ast.cmpop], ...]] = (
168 ast.Eq,
169 ast.NotEq,
170 ast.Lt,
171 ast.LtE,
172 ast.Gt,
173 ast.GtE,
174 ast.Is,
175 ast.IsNot,
176 ast.In,
177 ast.NotIn,
178)
180_ALLOWED_BOOL_OPS: Final[tuple[type[ast.boolop], ...]] = (ast.And, ast.Or)
183# ---------------------------------------------------------------------------
184# Exception
185# ---------------------------------------------------------------------------
188class PredicateRejected(Exception):
189 """Raised when a predicate source contains a disallowed construct.
191 The :attr:`reason` field is a short stable token (e.g.
192 ``"forbidden_call"``) so callers can render structured errors. The
193 :attr:`failing_node` field is the ``ast`` node that triggered the
194 rejection; it is ``None`` only when the source failed to parse at all.
195 """
197 def __init__(
198 self,
199 reason: str,
200 *,
201 failing_node: ast.AST | None = None,
202 message: str | None = None,
203 ) -> None:
204 self.reason: str = reason
205 self.failing_node: ast.AST | None = failing_node
206 self.lineno: int | None = (
207 getattr(failing_node, "lineno", None) if failing_node is not None else None
208 )
209 self.col_offset: int | None = (
210 getattr(failing_node, "col_offset", None) if failing_node is not None else None
211 )
212 rendered = message if message is not None else reason
213 if self.lineno is not None:
214 rendered = f"{rendered} (line {self.lineno}, col {self.col_offset})"
215 super().__init__(rendered)
218# ---------------------------------------------------------------------------
219# Validator
220# ---------------------------------------------------------------------------
223class _PredicateValidator(ast.NodeVisitor):
224 """Walk a predicate AST and reject any construct outside the allowlist.
226 The validator tracks per-scope local names introduced by comprehensions
227 so a tight expression like ``all(x > 0 for x in obs["xs"])`` works
228 while the comprehension target ``x`` cannot shadow ``obs`` or any of
229 the allowed callables.
230 """
232 def __init__(self) -> None:
233 # Stack of frozensets of locally-bound names. The base scope is
234 # empty; comprehensions push a frame containing their targets.
235 self._scopes: list[frozenset[str]] = [frozenset()]
237 # ---- helpers -------------------------------------------------------
239 def _current_locals(self) -> frozenset[str]:
240 return self._scopes[-1]
242 def _name_is_visible(self, name: str) -> bool:
243 return name in _ALLOWED_NAMES or name in self._current_locals()
245 @staticmethod
246 def _is_dunder(name: str) -> bool:
247 return name.startswith("__")
249 @staticmethod
250 def _reject(reason: str, node: ast.AST, message: str | None = None) -> NoReturn:
251 raise PredicateRejected(reason, failing_node=node, message=message)
253 def _push_scope(self, locals_: frozenset[str]) -> None:
254 self._scopes.append(self._current_locals() | locals_)
256 def _pop_scope(self) -> None:
257 self._scopes.pop()
259 def _collect_target_names(self, target: ast.AST) -> list[ast.Name]:
260 """Flatten a comprehension/assignment target into Name nodes.
262 Tuples and lists nest (``for (a, b) in pairs``); Starred wraps
263 (``for *xs, last in rows``). Anything else under a target is a
264 validation error reported by the caller.
265 """
266 if isinstance(target, ast.Name):
267 return [target]
268 if isinstance(target, (ast.Tuple, ast.List)):
269 collected: list[ast.Name] = []
270 for elt in target.elts:
271 collected.extend(self._collect_target_names(elt))
272 return collected
273 if isinstance(target, ast.Starred):
274 return self._collect_target_names(target.value)
275 # Anything else (Subscript, Attribute, ...) as a target is invalid.
276 self._reject(
277 "invalid_comprehension_target",
278 target,
279 "comprehension target must be a plain identifier",
280 )
282 # ---- top-level entry ----------------------------------------------
284 def visit_Expression(self, node: ast.Expression) -> None:
285 # ast.parse(..., mode="eval") guarantees a single Expression root;
286 # walk its body.
287 self.visit(node.body)
289 # ---- catch-all -----------------------------------------------------
291 def generic_visit(self, node: ast.AST) -> None:
292 # Default rejection: every node type we accept has a dedicated
293 # ``visit_*`` method below. If we reach generic_visit it means the
294 # source contained something we did not explicitly opt into
295 # (Lambda, NamedExpr, Yield, async constructs, FunctionDef, etc.).
296 self._reject(
297 "forbidden_node",
298 node,
299 f"{type(node).__name__} is not allowed in a predicate",
300 )
302 # ---- leaves --------------------------------------------------------
304 def visit_Constant(self, node: ast.Constant) -> None:
305 # Reject dunder strings even when used as plain data. We never
306 # need them in a numeric/boolean/string literal, and forbidding
307 # them closes off the most common escape patterns
308 # (``getattr(x, "__class__")``, ``obs["__import__"]``, etc.) even
309 # if a future change accidentally widens the allowlist.
310 if isinstance(node.value, str) and self._is_dunder(node.value):
311 self._reject(
312 "dunder_string",
313 node,
314 "string constants starting with '__' are not allowed",
315 )
316 # Other constants (int, float, bool, None, bytes, complex, str)
317 # are inert.
319 def visit_Name(self, node: ast.Name) -> None:
320 if self._is_dunder(node.id):
321 self._reject(
322 "dunder_name",
323 node,
324 f"identifier '{node.id}' starts with '__'",
325 )
326 if not self._name_is_visible(node.id):
327 self._reject(
328 "name_not_allowed",
329 node,
330 f"name '{node.id}' is not in the predicate allowlist",
331 )
333 # ---- containers ----------------------------------------------------
335 def visit_List(self, node: ast.List) -> None:
336 for elt in node.elts:
337 self.visit(elt)
339 def visit_Tuple(self, node: ast.Tuple) -> None:
340 for elt in node.elts:
341 self.visit(elt)
343 def visit_Set(self, node: ast.Set) -> None:
344 for elt in node.elts:
345 self.visit(elt)
347 def visit_Dict(self, node: ast.Dict) -> None:
348 for key in node.keys:
349 if key is not None:
350 self.visit(key)
351 else:
352 # ``{**other}`` unpacking would let an attacker splat
353 # arbitrary mappings; reject to keep the surface tight.
354 self._reject(
355 "dict_unpacking",
356 node,
357 "dict unpacking is not allowed in a predicate",
358 )
359 for value in node.values:
360 self.visit(value)
362 def visit_Starred(self, node: ast.Starred) -> None:
363 # ``[*xs]`` / ``f(*xs)`` — recurse into the inner expression so
364 # the nested Name still hits the allowlist check.
365 self.visit(node.value)
367 # ---- operators -----------------------------------------------------
369 def visit_BinOp(self, node: ast.BinOp) -> None:
370 if not isinstance(node.op, _ALLOWED_BIN_OPS):
371 self._reject(
372 "binop_not_allowed",
373 node,
374 f"binary operator {type(node.op).__name__} is not allowed",
375 )
376 self.visit(node.left)
377 self.visit(node.right)
379 def visit_UnaryOp(self, node: ast.UnaryOp) -> None:
380 if not isinstance(node.op, _ALLOWED_UNARY_OPS):
381 self._reject(
382 "unaryop_not_allowed",
383 node,
384 f"unary operator {type(node.op).__name__} is not allowed",
385 )
386 self.visit(node.operand)
388 def visit_BoolOp(self, node: ast.BoolOp) -> None:
389 if not isinstance(node.op, _ALLOWED_BOOL_OPS):
390 self._reject(
391 "boolop_not_allowed",
392 node,
393 f"bool operator {type(node.op).__name__} is not allowed",
394 )
395 for value in node.values:
396 self.visit(value)
398 def visit_Compare(self, node: ast.Compare) -> None:
399 for op in node.ops:
400 if not isinstance(op, _ALLOWED_COMPARE_OPS):
401 self._reject(
402 "compareop_not_allowed",
403 node,
404 f"comparison operator {type(op).__name__} is not allowed",
405 )
406 self.visit(node.left)
407 for comparator in node.comparators:
408 self.visit(comparator)
410 def visit_IfExp(self, node: ast.IfExp) -> None:
411 self.visit(node.test)
412 self.visit(node.body)
413 self.visit(node.orelse)
415 # ---- attribute and subscript --------------------------------------
417 def visit_Attribute(self, node: ast.Attribute) -> None:
418 # Three shapes are allowed:
419 #
420 # 1. ``obs.<attr>`` — single-level read off the data dict.
421 # 2. ``<inner>.<method>`` *only when* visited from
422 # ``visit_Call`` and ``<method>`` is in
423 # ``_ALLOWED_METHOD_CALLS``. ``visit_Call`` handles that
424 # case by validating the inner expression itself rather
425 # than recursing into ``visit_Attribute``, so by the time a
426 # bare ``Attribute`` lands here we know it is *not* the
427 # receiver of an allowed method call.
428 # 3. Nothing else: chained walks (``obs.a.b``), attributes on
429 # calls, and attributes on subscripts are all rejected.
430 if self._is_dunder(node.attr):
431 self._reject(
432 "dunder_attribute",
433 node,
434 f"attribute '{node.attr}' starts with '__'",
435 )
436 if not (isinstance(node.value, ast.Name) and node.value.id in _ALLOWED_DATA_NAMES):
437 self._reject(
438 "attribute_target_not_allowed",
439 node,
440 "attribute access is only allowed on 'obs' "
441 "(or as an allowlisted read-only method call on an otherwise "
442 "allowed expression)",
443 )
444 # The base Name is in _ALLOWED_DATA_NAMES, so we know it passes
445 # the visit_Name check; visit it anyway to stay regular.
446 self.visit(node.value)
448 def visit_Subscript(self, node: ast.Subscript) -> None:
449 # No special restriction beyond "the base Name must be on the
450 # allowlist", which falls out of recursing into ``node.value``.
451 # ``node.slice`` may itself contain Names and Calls; recurse so
452 # they hit the same allowlist gate.
453 self.visit(node.value)
454 self.visit(node.slice)
456 def visit_Slice(self, node: ast.Slice) -> None:
457 if node.lower is not None:
458 self.visit(node.lower)
459 if node.upper is not None:
460 self.visit(node.upper)
461 if node.step is not None:
462 self.visit(node.step)
464 # ---- calls ---------------------------------------------------------
466 def visit_Call(self, node: ast.Call) -> None:
467 # Two callable shapes are allowed:
468 #
469 # 1. Bare-name calls to one of ``_ALLOWED_CALLABLES`` —
470 # ``len(x)``, ``any(...)``, ``sorted(xs)``. The validator
471 # enforces the name appears on the allowlist.
472 # 2. Method calls of the form ``<expr>.<method>(...)`` where
473 # ``<method>`` is in ``_ALLOWED_METHOD_CALLS`` (explicit
474 # read-only accessors and transformations). The receiver expression
475 # is validated through the normal visit chain so a method
476 # call on something the predicate cannot otherwise see
477 # (e.g. ``getattr(x, 'y').get(...)``) is rejected at the
478 # receiver-validation step before the method allowlist is
479 # even consulted.
480 #
481 # Anything else — subscript-then-call (``builtins["eval"]()``),
482 # call-then-call (``factory()()``), method calls to non-
483 # allowlisted attribute names — is rejected.
484 if isinstance(node.func, ast.Attribute):
485 if self._is_dunder(node.func.attr):
486 self._reject(
487 "dunder_attribute",
488 node.func,
489 f"attribute '{node.func.attr}' starts with '__'",
490 )
491 if node.func.attr not in _ALLOWED_METHOD_CALLS:
492 self._reject(
493 "call_target_method_not_allowed",
494 node,
495 f"method '.{node.func.attr}()' is not allowed; "
496 f"the read-only method allowlist is "
497 f"{sorted(_ALLOWED_METHOD_CALLS)}",
498 )
499 # Validate the receiver itself. Recursing here (rather
500 # than into ``visit_Attribute``) bypasses the
501 # ``visit_Attribute`` rule that only ``obs.<attr>``
502 # is allowed — but only because the *method name* is on
503 # the explicit pure-accessor allowlist above. Any other
504 # attribute name still falls through ``visit_Attribute``'s
505 # tighter rules.
506 self.visit(node.func.value)
507 elif isinstance(node.func, ast.Name):
508 if node.func.id not in _ALLOWED_CALLABLES:
509 self._reject(
510 "call_target_not_allowed",
511 node,
512 f"call to '{node.func.id}' is not allowed",
513 )
514 else:
515 # Subscript-then-call, call-then-call, etc. — reject.
516 self._reject(
517 "call_target_not_name",
518 node,
519 "predicate calls must target a bare callable name or a read-only method",
520 )
521 for arg in node.args:
522 self.visit(arg)
523 for kw in node.keywords:
524 # ``**kwargs`` shows up as a keyword with arg=None; allow the
525 # value but recurse so its content is still validated.
526 self.visit(kw.value)
528 # ---- f-strings -----------------------------------------------------
530 def visit_JoinedStr(self, node: ast.JoinedStr) -> None:
531 for value in node.values:
532 self.visit(value)
534 def visit_FormattedValue(self, node: ast.FormattedValue) -> None:
535 self.visit(node.value)
536 if node.format_spec is not None:
537 self.visit(node.format_spec)
539 # ---- comprehensions -----------------------------------------------
541 def _validate_comprehensions(self, generators: list[ast.comprehension]) -> frozenset[str]:
542 """Walk comprehension generators and return their target names.
544 Each generator's ``iter`` is validated against the *outer* scope
545 (it cannot reference the targets of its own generator), then the
546 targets are added to the local set so the next generator's
547 ``ifs`` and any later ``iter`` can see them.
548 """
549 accumulated: set[str] = set()
550 for gen in generators:
551 if gen.is_async:
552 self._reject(
553 "async_comprehension",
554 gen.iter,
555 "async comprehensions are not allowed",
556 )
557 # Validate the iterable in the scope visible *before* this
558 # generator's targets are bound.
559 self.visit(gen.iter)
560 target_names = self._collect_target_names(gen.target)
561 for name_node in target_names:
562 if self._is_dunder(name_node.id):
563 self._reject(
564 "dunder_comprehension_target",
565 name_node,
566 f"comprehension target '{name_node.id}' starts with '__'",
567 )
568 if name_node.id in _ALLOWED_NAMES:
569 self._reject(
570 "comprehension_target_shadows_allowlist",
571 name_node,
572 f"comprehension target '{name_node.id}' shadows an allowlisted name",
573 )
574 accumulated.add(name_node.id)
575 # Subsequent ``ifs`` and any later generator may reference
576 # these targets; push them now.
577 self._push_scope(frozenset(accumulated))
578 try:
579 for if_clause in gen.ifs:
580 self.visit(if_clause)
581 finally:
582 self._pop_scope()
583 return frozenset(accumulated)
585 def _visit_comprehension_like(
586 self,
587 node: ast.ListComp | ast.SetComp | ast.GeneratorExp,
588 ) -> None:
589 locals_ = self._validate_comprehensions(node.generators)
590 self._push_scope(locals_)
591 try:
592 self.visit(node.elt)
593 finally:
594 self._pop_scope()
596 def visit_ListComp(self, node: ast.ListComp) -> None:
597 self._visit_comprehension_like(node)
599 def visit_SetComp(self, node: ast.SetComp) -> None:
600 self._visit_comprehension_like(node)
602 def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
603 self._visit_comprehension_like(node)
605 def visit_DictComp(self, node: ast.DictComp) -> None:
606 locals_ = self._validate_comprehensions(node.generators)
607 self._push_scope(locals_)
608 try:
609 self.visit(node.key)
610 self.visit(node.value)
611 finally:
612 self._pop_scope()
615# ---------------------------------------------------------------------------
616# Public API
617# ---------------------------------------------------------------------------
620def parse_predicate(src: str) -> ast.Expression:
621 """Parse and validate a predicate source string.
623 Returns the parsed :class:`ast.Expression` so callers can cache it and
624 feed it to :func:`evaluate_predicate` without reparsing. Raises
625 :class:`PredicateRejected` if the source fails to parse or contains
626 any disallowed construct.
627 """
628 if not isinstance(src, str):
629 raise PredicateRejected(
630 "not_a_string",
631 message="predicate source must be a str",
632 )
633 try:
634 parsed = ast.parse(src, mode="eval")
635 except SyntaxError as exc:
636 rejection = PredicateRejected(
637 "syntax_error",
638 message=f"could not parse predicate: {exc.msg}",
639 )
640 rejection.lineno = exc.lineno
641 rejection.col_offset = exc.offset
642 raise rejection from exc
643 _PredicateValidator().visit(parsed)
644 return parsed
647# Pre-built sandbox namespace. The double-empty ``__builtins__`` plus an
648# explicit safe-callable namespace is the established sandbox pattern: it
649# blocks lookup of every dangerous builtin (``__import__``, ``open``,
650# ``eval``, ``compile``, ``exec``, ``getattr``, ...) even if the validator
651# were ever bypassed by a future AST node we forgot about.
652_SAFE_GLOBALS: Final[dict[str, Any]] = {"__builtins__": {}}
653_SAFE_CALLABLES: Final[dict[str, Any]] = {
654 "len": len,
655 "min": min,
656 "max": max,
657 "sum": sum,
658 "abs": abs,
659 "any": any,
660 "all": all,
661 "sorted": sorted,
662 # Type coercions — pure, side-effect-free transforms used in
663 # idioms like ``str(r.get('count')) == '0'``. None of them can
664 # escape the empty-``__builtins__`` namespace regardless of input.
665 "str": str,
666 "int": int,
667 "float": float,
668 "bool": bool,
669}
672def evaluate_predicate(parsed: ast.Expression, obs: dict[str, Any]) -> Any:
673 """Evaluate an already-validated predicate AST against ``obs``.
675 The caller is responsible for passing only an :class:`ast.Expression`
676 that came from :func:`parse_predicate`; the function does not
677 re-validate. Compilation is per-call to keep the function pure (the
678 AST itself is the cached unit of work). Returns whatever the
679 expression evaluates to — typically a ``bool``, but the criterion
680 layer handles other values.
681 """
682 code = compile(parsed, "<predicate>", "eval")
683 # Names referenced from inside a comprehension or generator
684 # expression resolve through the enclosing function's *globals*
685 # at runtime, not the ``locals`` mapping passed to ``eval`` —
686 # because each comprehension compiles to its own implicit
687 # function scope. So validated free names (``obs`` plus the
688 # safe callables) must live in the globals dict to remain
689 # visible from inside ``any(str(r) ... for r in obs[...])``
690 # idioms; an earlier "locals-only" arrangement raised
691 # ``NameError: name 'str' is not defined`` at runtime even
692 # though parse_predicate had accepted the source. The empty
693 # ``__builtins__`` still keeps the sandbox tight: every name
694 # the body can reach is one we put in the globals dict
695 # ourselves.
696 eval_globals: dict[str, Any] = {**_SAFE_GLOBALS, "obs": obs, **_SAFE_CALLABLES}
697 return eval( # nosec B307 # nosemgrep: python.lang.security.audit.eval-detected.eval-detected
698 code, eval_globals, {}
699 )