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

1"""Restricted AST evaluator for ``Criterion(kind="predicate")`` expressions. 

2 

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. 

10 

11The sandbox has two layers: 

12 

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. 

22 

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``. 

28 

29Operators: arithmetic (``+ - * / // % ** @``), unary (``+ - not ~``), 

30comparisons (``< <= > >= == != is is_not in not_in``), boolean 

31(``and or``), and the ternary ``a if b else c``. 

32 

33Containers and collections: ``List``, ``Tuple``, ``Dict``, ``Set``, plus 

34``ListComp``, ``SetComp``, ``DictComp``, ``GeneratorExp`` (their iteration 

35targets must not shadow a name from the allowlist). 

36 

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``. 

47 

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. 

54 

55Subscripts: any ``value[...]`` chain whose ultimate base is an allowlisted 

56name. Rejection happens automatically because every nested ``Name`` lookup 

57is validated. 

58 

59f-strings: ``JoinedStr`` and ``FormattedValue`` recurse normally so any 

60embedded name lookup re-enters this same allowlist check. 

61 

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""" 

70 

71from __future__ import annotations 

72 

73import ast 

74from typing import Any, Final, NoReturn 

75 

76# --------------------------------------------------------------------------- 

77# Allowlists 

78# --------------------------------------------------------------------------- 

79 

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. 

84 

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""" 

94 

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. 

99 

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: 

104 

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. 

118 

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. 

127 

128Method-call gating still applies in two places: 

129 

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""" 

142 

143_ALLOWED_DATA_NAMES: Final[frozenset[str]] = frozenset({"obs"}) 

144"""Top-level data names the predicate may read.""" 

145 

146_ALLOWED_NAMES: Final[frozenset[str]] = _ALLOWED_DATA_NAMES | _ALLOWED_CALLABLES 

147"""Every globally-allowed identifier the predicate may reference.""" 

148 

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) 

159 

160_ALLOWED_UNARY_OPS: Final[tuple[type[ast.unaryop], ...]] = ( 

161 ast.UAdd, 

162 ast.USub, 

163 ast.Not, 

164 ast.Invert, 

165) 

166 

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) 

179 

180_ALLOWED_BOOL_OPS: Final[tuple[type[ast.boolop], ...]] = (ast.And, ast.Or) 

181 

182 

183# --------------------------------------------------------------------------- 

184# Exception 

185# --------------------------------------------------------------------------- 

186 

187 

188class PredicateRejected(Exception): 

189 """Raised when a predicate source contains a disallowed construct. 

190 

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 """ 

196 

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) 

216 

217 

218# --------------------------------------------------------------------------- 

219# Validator 

220# --------------------------------------------------------------------------- 

221 

222 

223class _PredicateValidator(ast.NodeVisitor): 

224 """Walk a predicate AST and reject any construct outside the allowlist. 

225 

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 """ 

231 

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()] 

236 

237 # ---- helpers ------------------------------------------------------- 

238 

239 def _current_locals(self) -> frozenset[str]: 

240 return self._scopes[-1] 

241 

242 def _name_is_visible(self, name: str) -> bool: 

243 return name in _ALLOWED_NAMES or name in self._current_locals() 

244 

245 @staticmethod 

246 def _is_dunder(name: str) -> bool: 

247 return name.startswith("__") 

248 

249 @staticmethod 

250 def _reject(reason: str, node: ast.AST, message: str | None = None) -> NoReturn: 

251 raise PredicateRejected(reason, failing_node=node, message=message) 

252 

253 def _push_scope(self, locals_: frozenset[str]) -> None: 

254 self._scopes.append(self._current_locals() | locals_) 

255 

256 def _pop_scope(self) -> None: 

257 self._scopes.pop() 

258 

259 def _collect_target_names(self, target: ast.AST) -> list[ast.Name]: 

260 """Flatten a comprehension/assignment target into Name nodes. 

261 

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 ) 

281 

282 # ---- top-level entry ---------------------------------------------- 

283 

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) 

288 

289 # ---- catch-all ----------------------------------------------------- 

290 

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 ) 

301 

302 # ---- leaves -------------------------------------------------------- 

303 

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. 

318 

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 ) 

332 

333 # ---- containers ---------------------------------------------------- 

334 

335 def visit_List(self, node: ast.List) -> None: 

336 for elt in node.elts: 

337 self.visit(elt) 

338 

339 def visit_Tuple(self, node: ast.Tuple) -> None: 

340 for elt in node.elts: 

341 self.visit(elt) 

342 

343 def visit_Set(self, node: ast.Set) -> None: 

344 for elt in node.elts: 

345 self.visit(elt) 

346 

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) 

361 

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) 

366 

367 # ---- operators ----------------------------------------------------- 

368 

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) 

378 

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) 

387 

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) 

397 

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) 

409 

410 def visit_IfExp(self, node: ast.IfExp) -> None: 

411 self.visit(node.test) 

412 self.visit(node.body) 

413 self.visit(node.orelse) 

414 

415 # ---- attribute and subscript -------------------------------------- 

416 

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) 

447 

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) 

455 

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) 

463 

464 # ---- calls --------------------------------------------------------- 

465 

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) 

527 

528 # ---- f-strings ----------------------------------------------------- 

529 

530 def visit_JoinedStr(self, node: ast.JoinedStr) -> None: 

531 for value in node.values: 

532 self.visit(value) 

533 

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) 

538 

539 # ---- comprehensions ----------------------------------------------- 

540 

541 def _validate_comprehensions(self, generators: list[ast.comprehension]) -> frozenset[str]: 

542 """Walk comprehension generators and return their target names. 

543 

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) 

584 

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() 

595 

596 def visit_ListComp(self, node: ast.ListComp) -> None: 

597 self._visit_comprehension_like(node) 

598 

599 def visit_SetComp(self, node: ast.SetComp) -> None: 

600 self._visit_comprehension_like(node) 

601 

602 def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: 

603 self._visit_comprehension_like(node) 

604 

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() 

613 

614 

615# --------------------------------------------------------------------------- 

616# Public API 

617# --------------------------------------------------------------------------- 

618 

619 

620def parse_predicate(src: str) -> ast.Expression: 

621 """Parse and validate a predicate source string. 

622 

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 

645 

646 

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} 

670 

671 

672def evaluate_predicate(parsed: ast.Expression, obs: dict[str, Any]) -> Any: 

673 """Evaluate an already-validated predicate AST against ``obs``. 

674 

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 )