Coverage for gco_mcp/mission/predicate.py: 92.33%
203 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +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): 273 ↛ 276line 273 didn't jump to line 276 because the condition on line 273 was always true
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 )
281 return [] # unreachable; _reject raises
283 # ---- top-level entry ----------------------------------------------
285 def visit_Expression(self, node: ast.Expression) -> None:
286 # ast.parse(..., mode="eval") guarantees a single Expression root;
287 # walk its body.
288 self.visit(node.body)
290 # ---- catch-all -----------------------------------------------------
292 def generic_visit(self, node: ast.AST) -> None:
293 # Default rejection: every node type we accept has a dedicated
294 # ``visit_*`` method below. If we reach generic_visit it means the
295 # source contained something we did not explicitly opt into
296 # (Lambda, NamedExpr, Yield, async constructs, FunctionDef, etc.).
297 self._reject(
298 "forbidden_node",
299 node,
300 f"{type(node).__name__} is not allowed in a predicate",
301 )
303 # ---- leaves --------------------------------------------------------
305 def visit_Constant(self, node: ast.Constant) -> None:
306 # Reject dunder strings even when used as plain data. We never
307 # need them in a numeric/boolean/string literal, and forbidding
308 # them closes off the most common escape patterns
309 # (``getattr(x, "__class__")``, ``obs["__import__"]``, etc.) even
310 # if a future change accidentally widens the allowlist.
311 if isinstance(node.value, str) and self._is_dunder(node.value):
312 self._reject(
313 "dunder_string",
314 node,
315 "string constants starting with '__' are not allowed",
316 )
317 # Other constants (int, float, bool, None, bytes, complex, str)
318 # are inert.
320 def visit_Name(self, node: ast.Name) -> None:
321 if self._is_dunder(node.id):
322 self._reject(
323 "dunder_name",
324 node,
325 f"identifier '{node.id}' starts with '__'",
326 )
327 if not self._name_is_visible(node.id):
328 self._reject(
329 "name_not_allowed",
330 node,
331 f"name '{node.id}' is not in the predicate allowlist",
332 )
334 # ---- containers ----------------------------------------------------
336 def visit_List(self, node: ast.List) -> None:
337 for elt in node.elts:
338 self.visit(elt)
340 def visit_Tuple(self, node: ast.Tuple) -> None:
341 for elt in node.elts:
342 self.visit(elt)
344 def visit_Set(self, node: ast.Set) -> None:
345 for elt in node.elts:
346 self.visit(elt)
348 def visit_Dict(self, node: ast.Dict) -> None:
349 for key in node.keys:
350 if key is not None:
351 self.visit(key)
352 else:
353 # ``{**other}`` unpacking would let an attacker splat
354 # arbitrary mappings; reject to keep the surface tight.
355 self._reject(
356 "dict_unpacking",
357 node,
358 "dict unpacking is not allowed in a predicate",
359 )
360 for value in node.values:
361 self.visit(value)
363 def visit_Starred(self, node: ast.Starred) -> None:
364 # ``[*xs]`` / ``f(*xs)`` — recurse into the inner expression so
365 # the nested Name still hits the allowlist check.
366 self.visit(node.value)
368 # ---- operators -----------------------------------------------------
370 def visit_BinOp(self, node: ast.BinOp) -> None:
371 if not isinstance(node.op, _ALLOWED_BIN_OPS): 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true
372 self._reject(
373 "binop_not_allowed",
374 node,
375 f"binary operator {type(node.op).__name__} is not allowed",
376 )
377 self.visit(node.left)
378 self.visit(node.right)
380 def visit_UnaryOp(self, node: ast.UnaryOp) -> None:
381 if not isinstance(node.op, _ALLOWED_UNARY_OPS): 381 ↛ 382line 381 didn't jump to line 382 because the condition on line 381 was never true
382 self._reject(
383 "unaryop_not_allowed",
384 node,
385 f"unary operator {type(node.op).__name__} is not allowed",
386 )
387 self.visit(node.operand)
389 def visit_BoolOp(self, node: ast.BoolOp) -> None:
390 if not isinstance(node.op, _ALLOWED_BOOL_OPS): 390 ↛ 391line 390 didn't jump to line 391 because the condition on line 390 was never true
391 self._reject(
392 "boolop_not_allowed",
393 node,
394 f"bool operator {type(node.op).__name__} is not allowed",
395 )
396 for value in node.values:
397 self.visit(value)
399 def visit_Compare(self, node: ast.Compare) -> None:
400 for op in node.ops:
401 if not isinstance(op, _ALLOWED_COMPARE_OPS): 401 ↛ 402line 401 didn't jump to line 402 because the condition on line 401 was never true
402 self._reject(
403 "compareop_not_allowed",
404 node,
405 f"comparison operator {type(op).__name__} is not allowed",
406 )
407 self.visit(node.left)
408 for comparator in node.comparators:
409 self.visit(comparator)
411 def visit_IfExp(self, node: ast.IfExp) -> None:
412 self.visit(node.test)
413 self.visit(node.body)
414 self.visit(node.orelse)
416 # ---- attribute and subscript --------------------------------------
418 def visit_Attribute(self, node: ast.Attribute) -> None:
419 # Three shapes are allowed:
420 #
421 # 1. ``obs.<attr>`` — single-level read off the data dict.
422 # 2. ``<inner>.<method>`` *only when* visited from
423 # ``visit_Call`` and ``<method>`` is in
424 # ``_ALLOWED_METHOD_CALLS``. ``visit_Call`` handles that
425 # case by validating the inner expression itself rather
426 # than recursing into ``visit_Attribute``, so by the time a
427 # bare ``Attribute`` lands here we know it is *not* the
428 # receiver of an allowed method call.
429 # 3. Nothing else: chained walks (``obs.a.b``), attributes on
430 # calls, and attributes on subscripts are all rejected.
431 if self._is_dunder(node.attr):
432 self._reject(
433 "dunder_attribute",
434 node,
435 f"attribute '{node.attr}' starts with '__'",
436 )
437 if not (isinstance(node.value, ast.Name) and node.value.id in _ALLOWED_DATA_NAMES): 437 ↛ 447line 437 didn't jump to line 447 because the condition on line 437 was always true
438 self._reject(
439 "attribute_target_not_allowed",
440 node,
441 "attribute access is only allowed on 'obs' "
442 "(or as an allowlisted read-only method call on an otherwise "
443 "allowed expression)",
444 )
445 # The base Name is in _ALLOWED_DATA_NAMES, so we know it passes
446 # the visit_Name check; visit it anyway to stay regular.
447 self.visit(node.value)
449 def visit_Subscript(self, node: ast.Subscript) -> None:
450 # No special restriction beyond "the base Name must be on the
451 # allowlist", which falls out of recursing into ``node.value``.
452 # ``node.slice`` may itself contain Names and Calls; recurse so
453 # they hit the same allowlist gate.
454 self.visit(node.value)
455 self.visit(node.slice)
457 def visit_Slice(self, node: ast.Slice) -> None:
458 if node.lower is not None: 458 ↛ 460line 458 didn't jump to line 460 because the condition on line 458 was always true
459 self.visit(node.lower)
460 if node.upper is not None: 460 ↛ 462line 460 didn't jump to line 462 because the condition on line 460 was always true
461 self.visit(node.upper)
462 if node.step is not None: 462 ↛ exitline 462 didn't return from function 'visit_Slice' because the condition on line 462 was always true
463 self.visit(node.step)
465 # ---- calls ---------------------------------------------------------
467 def visit_Call(self, node: ast.Call) -> None:
468 # Two callable shapes are allowed:
469 #
470 # 1. Bare-name calls to one of ``_ALLOWED_CALLABLES`` —
471 # ``len(x)``, ``any(...)``, ``sorted(xs)``. The validator
472 # enforces the name appears on the allowlist.
473 # 2. Method calls of the form ``<expr>.<method>(...)`` where
474 # ``<method>`` is in ``_ALLOWED_METHOD_CALLS`` (explicit
475 # read-only accessors and transformations). The receiver expression
476 # is validated through the normal visit chain so a method
477 # call on something the predicate cannot otherwise see
478 # (e.g. ``getattr(x, 'y').get(...)``) is rejected at the
479 # receiver-validation step before the method allowlist is
480 # even consulted.
481 #
482 # Anything else — subscript-then-call (``builtins["eval"]()``),
483 # call-then-call (``factory()()``), method calls to non-
484 # allowlisted attribute names — is rejected.
485 if isinstance(node.func, ast.Attribute):
486 if self._is_dunder(node.func.attr):
487 self._reject(
488 "dunder_attribute",
489 node.func,
490 f"attribute '{node.func.attr}' starts with '__'",
491 )
492 if node.func.attr not in _ALLOWED_METHOD_CALLS:
493 self._reject(
494 "call_target_method_not_allowed",
495 node,
496 f"method '.{node.func.attr}()' is not allowed; "
497 f"the read-only method allowlist is "
498 f"{sorted(_ALLOWED_METHOD_CALLS)}",
499 )
500 # Validate the receiver itself. Recursing here (rather
501 # than into ``visit_Attribute``) bypasses the
502 # ``visit_Attribute`` rule that only ``obs.<attr>``
503 # is allowed — but only because the *method name* is on
504 # the explicit pure-accessor allowlist above. Any other
505 # attribute name still falls through ``visit_Attribute``'s
506 # tighter rules.
507 self.visit(node.func.value)
508 elif isinstance(node.func, ast.Name):
509 if node.func.id not in _ALLOWED_CALLABLES:
510 self._reject(
511 "call_target_not_allowed",
512 node,
513 f"call to '{node.func.id}' is not allowed",
514 )
515 else:
516 # Subscript-then-call, call-then-call, etc. — reject.
517 self._reject(
518 "call_target_not_name",
519 node,
520 "predicate calls must target a bare callable name or a read-only method",
521 )
522 for arg in node.args:
523 self.visit(arg)
524 for kw in node.keywords: 524 ↛ 527line 524 didn't jump to line 527 because the loop on line 524 never started
525 # ``**kwargs`` shows up as a keyword with arg=None; allow the
526 # value but recurse so its content is still validated.
527 self.visit(kw.value)
529 # ---- f-strings -----------------------------------------------------
531 def visit_JoinedStr(self, node: ast.JoinedStr) -> None:
532 for value in node.values:
533 self.visit(value)
535 def visit_FormattedValue(self, node: ast.FormattedValue) -> None:
536 self.visit(node.value)
537 if node.format_spec is not None: 537 ↛ 538line 537 didn't jump to line 538 because the condition on line 537 was never true
538 self.visit(node.format_spec)
540 # ---- comprehensions -----------------------------------------------
542 def _validate_comprehensions(self, generators: list[ast.comprehension]) -> frozenset[str]:
543 """Walk comprehension generators and return their target names.
545 Each generator's ``iter`` is validated against the *outer* scope
546 (it cannot reference the targets of its own generator), then the
547 targets are added to the local set so the next generator's
548 ``ifs`` and any later ``iter`` can see them.
549 """
550 accumulated: set[str] = set()
551 for gen in generators:
552 if gen.is_async:
553 self._reject(
554 "async_comprehension",
555 gen.iter,
556 "async comprehensions are not allowed",
557 )
558 # Validate the iterable in the scope visible *before* this
559 # generator's targets are bound.
560 self.visit(gen.iter)
561 target_names = self._collect_target_names(gen.target)
562 for name_node in target_names:
563 if self._is_dunder(name_node.id): 563 ↛ 564line 563 didn't jump to line 564 because the condition on line 563 was never true
564 self._reject(
565 "dunder_comprehension_target",
566 name_node,
567 f"comprehension target '{name_node.id}' starts with '__'",
568 )
569 if name_node.id in _ALLOWED_NAMES:
570 self._reject(
571 "comprehension_target_shadows_allowlist",
572 name_node,
573 f"comprehension target '{name_node.id}' shadows an allowlisted name",
574 )
575 accumulated.add(name_node.id)
576 # Subsequent ``ifs`` and any later generator may reference
577 # these targets; push them now.
578 self._push_scope(frozenset(accumulated))
579 try:
580 for if_clause in gen.ifs:
581 self.visit(if_clause)
582 finally:
583 self._pop_scope()
584 return frozenset(accumulated)
586 def _visit_comprehension_like(
587 self,
588 node: ast.ListComp | ast.SetComp | ast.GeneratorExp,
589 ) -> None:
590 locals_ = self._validate_comprehensions(node.generators)
591 self._push_scope(locals_)
592 try:
593 self.visit(node.elt)
594 finally:
595 self._pop_scope()
597 def visit_ListComp(self, node: ast.ListComp) -> None:
598 self._visit_comprehension_like(node)
600 def visit_SetComp(self, node: ast.SetComp) -> None:
601 self._visit_comprehension_like(node)
603 def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
604 self._visit_comprehension_like(node)
606 def visit_DictComp(self, node: ast.DictComp) -> None:
607 locals_ = self._validate_comprehensions(node.generators)
608 self._push_scope(locals_)
609 try:
610 self.visit(node.key)
611 self.visit(node.value)
612 finally:
613 self._pop_scope()
616# ---------------------------------------------------------------------------
617# Public API
618# ---------------------------------------------------------------------------
621def parse_predicate(src: str) -> ast.Expression:
622 """Parse and validate a predicate source string.
624 Returns the parsed :class:`ast.Expression` so callers can cache it and
625 feed it to :func:`evaluate_predicate` without reparsing. Raises
626 :class:`PredicateRejected` if the source fails to parse or contains
627 any disallowed construct.
628 """
629 if not isinstance(src, str):
630 raise PredicateRejected(
631 "not_a_string",
632 message="predicate source must be a str",
633 )
634 try:
635 parsed = ast.parse(src, mode="eval")
636 except SyntaxError as exc:
637 rejection = PredicateRejected(
638 "syntax_error",
639 message=f"could not parse predicate: {exc.msg}",
640 )
641 rejection.lineno = exc.lineno
642 rejection.col_offset = exc.offset
643 raise rejection from exc
644 _PredicateValidator().visit(parsed)
645 return parsed
648# Pre-built sandbox namespace. The double-empty ``__builtins__`` plus an
649# explicit safe-callable namespace is the established sandbox pattern: it
650# blocks lookup of every dangerous builtin (``__import__``, ``open``,
651# ``eval``, ``compile``, ``exec``, ``getattr``, ...) even if the validator
652# were ever bypassed by a future AST node we forgot about.
653_SAFE_GLOBALS: Final[dict[str, Any]] = {"__builtins__": {}}
654_SAFE_CALLABLES: Final[dict[str, Any]] = {
655 "len": len,
656 "min": min,
657 "max": max,
658 "sum": sum,
659 "abs": abs,
660 "any": any,
661 "all": all,
662 "sorted": sorted,
663 # Type coercions — pure, side-effect-free transforms used in
664 # idioms like ``str(r.get('count')) == '0'``. None of them can
665 # escape the empty-``__builtins__`` namespace regardless of input.
666 "str": str,
667 "int": int,
668 "float": float,
669 "bool": bool,
670}
673def evaluate_predicate(parsed: ast.Expression, obs: dict[str, Any]) -> Any:
674 """Evaluate an already-validated predicate AST against ``obs``.
676 The caller is responsible for passing only an :class:`ast.Expression`
677 that came from :func:`parse_predicate`; the function does not
678 re-validate. Compilation is per-call to keep the function pure (the
679 AST itself is the cached unit of work). Returns whatever the
680 expression evaluates to — typically a ``bool``, but the criterion
681 layer handles other values.
682 """
683 code = compile(parsed, "<predicate>", "eval")
684 # Names referenced from inside a comprehension or generator
685 # expression resolve through the enclosing function's *globals*
686 # at runtime, not the ``locals`` mapping passed to ``eval`` —
687 # because each comprehension compiles to its own implicit
688 # function scope. So validated free names (``obs`` plus the
689 # safe callables) must live in the globals dict to remain
690 # visible from inside ``any(str(r) ... for r in obs[...])``
691 # idioms; an earlier "locals-only" arrangement raised
692 # ``NameError: name 'str' is not defined`` at runtime even
693 # though parse_predicate had accepted the source. The empty
694 # ``__builtins__`` still keeps the sandbox tight: every name
695 # the body can reach is one we put in the globals dict
696 # ourselves.
697 eval_globals: dict[str, Any] = {**_SAFE_GLOBALS, "obs": obs, **_SAFE_CALLABLES}
698 return eval( # nosemgrep: python.lang.security.audit.eval-detected.eval-detected
699 code, eval_globals, {}
700 ) # noqa: S307