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

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

282 

283 # ---- top-level entry ---------------------------------------------- 

284 

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) 

289 

290 # ---- catch-all ----------------------------------------------------- 

291 

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 ) 

302 

303 # ---- leaves -------------------------------------------------------- 

304 

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. 

319 

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 ) 

333 

334 # ---- containers ---------------------------------------------------- 

335 

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

337 for elt in node.elts: 

338 self.visit(elt) 

339 

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

341 for elt in node.elts: 

342 self.visit(elt) 

343 

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

345 for elt in node.elts: 

346 self.visit(elt) 

347 

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) 

362 

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) 

367 

368 # ---- operators ----------------------------------------------------- 

369 

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) 

379 

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) 

388 

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) 

398 

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) 

410 

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

412 self.visit(node.test) 

413 self.visit(node.body) 

414 self.visit(node.orelse) 

415 

416 # ---- attribute and subscript -------------------------------------- 

417 

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) 

448 

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) 

456 

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) 

464 

465 # ---- calls --------------------------------------------------------- 

466 

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) 

528 

529 # ---- f-strings ----------------------------------------------------- 

530 

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

532 for value in node.values: 

533 self.visit(value) 

534 

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) 

539 

540 # ---- comprehensions ----------------------------------------------- 

541 

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

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

544 

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) 

585 

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

596 

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

598 self._visit_comprehension_like(node) 

599 

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

601 self._visit_comprehension_like(node) 

602 

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

604 self._visit_comprehension_like(node) 

605 

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

614 

615 

616# --------------------------------------------------------------------------- 

617# Public API 

618# --------------------------------------------------------------------------- 

619 

620 

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

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

623 

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 

646 

647 

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} 

671 

672 

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

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

675 

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