Coverage for gco_mcp/mission/sandbox.py: 87.41%

511 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 21:22 +0000

1"""Restricted AST validator for Mission ``Strategy.script`` source. 

2 

3Where a ``Criterion(kind="predicate")`` carries a single expression, a 

4``Strategy.script`` carries a multi-statement Python module that runs 

5inside the Mission sandbox to drive an iteration. Both surfaces accept 

6untrusted operator input, so both go through a parse-time AST allowlist 

7before any execution. This module owns the script side: it parses 

8scripts in ``exec`` mode, walks the tree with an explicit list of 

9allowed nodes, and rejects everything else with :class:`ScriptRejected`. 

10 

11The script surface is wider than the predicate surface — multi-statement 

12control flow, helper function definitions, named-exception ``try`` / 

13``except`` / ``finally`` blocks, plus calls to the operator-supplied 

14tool allowlist — so this module is its own validator rather than a 

15shared base class. The structural decisions (an :class:`ast.NodeVisitor` 

16that defines a ``visit_*`` for every accepted node and rejects in 

17``generic_visit``, an exception type carrying ``reason`` / 

18``failing_node`` / ``lineno`` / ``col_offset``, dunder filtering on 

19strings and identifiers, comprehension-target shadowing checks) mirror 

20:mod:`mcp.mission.predicate` so the two layers reject the same shapes 

21the same way. 

22 

23Two layers, same as the predicate sandbox: 

24 

251. **Parse-time validation.** :func:`validate_script_ast` parses the 

26 source in ``exec`` mode and walks the tree with 

27 :class:`_ScriptValidator`. The first disallowed construct raises 

28 :class:`ScriptRejected`; the script never runs. 

292. **Run-time isolation.** The runtime layer (the 

30 :class:`MissionSandbox` wrapper around ``MontySandboxProvider``) 

31 executes a validated script under shared duration / memory limits 

32 with an explicit namespace that withholds dangerous builtins like 

33 ``open`` / ``getattr`` / ``__import__``. Even a tree that smuggled 

34 past this validator would fail at lookup. 

35 

36Allowed surface 

37--------------- 

38**Statements:** ``Module``, ``Expr``, ``Assign``, ``AugAssign``, 

39``AnnAssign``, ``If``, ``While``, ``For``, ``Pass``, ``Break``, 

40``Continue``, ``Return``, ``FunctionDef`` (no decorators), ``Try`` 

41(named-exception handlers only), ``Raise``. 

42 

43**Expressions:** constants, names from the allowlist, container 

44literals (``List`` / ``Tuple`` / ``Set`` / ``Dict``), comprehensions 

45(``ListComp`` / ``SetComp`` / ``DictComp`` / ``GeneratorExp``), 

46``BinOp`` / ``UnaryOp`` / ``BoolOp`` / ``Compare`` / ``IfExp``, 

47subscript and slice access, f-strings, lambdas, the walrus operator, 

48plus calls. 

49 

50**Names visible to a script (the *base scope*):** 

51 

52- ``mission`` — the per-iteration namespace; the only allowed 

53 attribute access is ``mission.observe`` and ``mission.event``. 

54- The pure stdlib callables ``len``, ``min``, ``max``, ``sum``, 

55 ``abs``, ``any``, ``all``, ``sorted``, ``range``, ``enumerate``, 

56 ``zip``, ``list``, ``dict``, ``tuple``, ``set``, ``str``, ``int``, 

57 ``float``, ``bool``. 

58- A small set of built-in exception classes so ``raise ValueError(...)`` 

59 and ``except KeyError as e:`` both work without importing. 

60- Every tool name the operator placed on the per-session allowlist. 

61 

62**Calls** may target a bare name from the base scope, a name a script 

63introduced (a function it defined or a value it bound), or one of the 

64two attribute calls ``mission.observe(...)`` / ``mission.event(...)``. 

65``exec``, ``eval``, ``compile``, and ``__import__`` are rejected by 

66name even if a script binds those identifiers locally. 

67 

68Rejected outright 

69----------------- 

70``Import`` / ``ImportFrom``, ``ClassDef``, ``AsyncFunctionDef`` / 

71``AsyncFor`` / ``AsyncWith``, ``Yield`` / ``YieldFrom``, ``Global`` / 

72``Nonlocal``, ``Match``, ``With``, ``Assert``, ``Delete``, decorators 

73(the allowlist is currently empty), bare ``except:`` clauses, 

74attribute access on anything other than ``mission``, calls on 

75attributes / subscripts / other calls, dunder strings and identifiers, 

76and any binding (``Assign``, ``AnnAssign``, ``AugAssign``, walrus, 

77function parameter, function name, comprehension target, ``for`` 

78target, ``except as`` name) whose name shadows a base-scope identifier. 

79 

80``Await`` carries a single, narrow exception: ``await <tool>(...)`` 

81where ``<tool>`` is a bare name on the per-session tool allowlist. 

82The runtime layer below exposes every allowlisted tool through the 

83underlying Monty ``external_functions`` channel as a coroutine 

84factory, so the script must ``await`` the call to receive the 

85dispatcher's return value rather than a coroutine object. Every 

86other ``Await`` shape — ``await name`` on a non-call, ``await 

87mission.observe(...)``, ``await some_other_tool()`` for a tool not on 

88the allowlist, ``await (lambda: ...)()`` — stays rejected with 

89reason ``await_not_allowed``. 

90""" 

91 

92from __future__ import annotations 

93 

94import ast 

95from collections.abc import Iterable 

96from typing import Final, NoReturn 

97 

98# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

99# Generated at (UTC): 2026-07-18T01:03:40Z 

100# Flowchart(s) generated from this file: 

101# * ``validate_script_ast`` -> ``diagrams/code_diagrams/gco_mcp/mission/sandbox.validate_script_ast.html`` 

102# (PNG: ``diagrams/code_diagrams/gco_mcp/mission/sandbox.validate_script_ast.png``) 

103# Regenerate with ``python diagrams/code_diagrams/generate.py``. 

104# <pyflowchart-code-diagram> END 

105 

106 

107# --------------------------------------------------------------------------- 

108# Allowlists 

109# --------------------------------------------------------------------------- 

110 

111_SAFE_BUILTINS: Final[frozenset[str]] = frozenset( 

112 { 

113 "len", 

114 "min", 

115 "max", 

116 "sum", 

117 "abs", 

118 "any", 

119 "all", 

120 "sorted", 

121 "range", 

122 "enumerate", 

123 "zip", 

124 "list", 

125 "dict", 

126 "tuple", 

127 "set", 

128 "str", 

129 "int", 

130 "float", 

131 "bool", 

132 } 

133) 

134"""Pure stdlib callables a script may look up by bare name.""" 

135 

136_ALLOWED_EXCEPTION_NAMES: Final[frozenset[str]] = frozenset( 

137 { 

138 "Exception", 

139 "ValueError", 

140 "TypeError", 

141 "KeyError", 

142 "IndexError", 

143 "AttributeError", 

144 "LookupError", 

145 "RuntimeError", 

146 "ArithmeticError", 

147 "ZeroDivisionError", 

148 "OverflowError", 

149 "OSError", 

150 "FileNotFoundError", 

151 "TimeoutError", 

152 "ConnectionError", 

153 "StopIteration", 

154 "AssertionError", 

155 } 

156) 

157"""Built-in exception classes a script may name in ``raise`` and ``except``. 

158 

159Including these in the base scope is what lets a script say 

160``except ValueError as e:`` or ``raise RuntimeError("msg")`` without an 

161``import``. Constructing an exception instance is side-effect-free, so 

162exposing the class is no broader than exposing the safe builtins. 

163""" 

164 

165_MISSION_NAMESPACE_NAME: Final[str] = "mission" 

166"""Top-level identifier reserved for the per-iteration helper namespace.""" 

167 

168_MISSION_HELPER_ATTRIBUTES: Final[frozenset[str]] = frozenset({"observe", "event"}) 

169"""Only attributes the validator accepts on the ``mission`` namespace.""" 

170 

171_FORBIDDEN_CALL_TARGETS: Final[frozenset[str]] = frozenset( 

172 {"exec", "eval", "compile", "__import__"} 

173) 

174"""Names whose call form is rejected by name even if a script shadows them. 

175 

176A script could in principle write ``def exec(): ...`` and then call its 

177own local. Rejecting these names at the call site as well as via the 

178dunder filter (for ``__import__``) closes the gap. 

179""" 

180 

181_ALLOWED_DECORATORS: Final[frozenset[str]] = frozenset() 

182"""Decorator names a function definition may carry. 

183 

184Currently empty: any ``@decorator`` on a ``FunctionDef`` is rejected. 

185The hook is here so a future iteration can vet a small set of operator- 

186facing helpers (e.g. a retry decorator) by editing only this constant. 

187""" 

188 

189_ALLOWED_BIN_OPS: Final[tuple[type[ast.operator], ...]] = ( 

190 ast.Add, 

191 ast.Sub, 

192 ast.Mult, 

193 ast.Div, 

194 ast.FloorDiv, 

195 ast.Mod, 

196 ast.Pow, 

197 ast.MatMult, 

198) 

199 

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

201 ast.UAdd, 

202 ast.USub, 

203 ast.Not, 

204 ast.Invert, 

205) 

206 

207_ALLOWED_COMPARE_OPS: Final[tuple[type[ast.cmpop], ...]] = ( 

208 ast.Eq, 

209 ast.NotEq, 

210 ast.Lt, 

211 ast.LtE, 

212 ast.Gt, 

213 ast.GtE, 

214 ast.Is, 

215 ast.IsNot, 

216 ast.In, 

217 ast.NotIn, 

218) 

219 

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

221 

222 

223# --------------------------------------------------------------------------- 

224# Exception 

225# --------------------------------------------------------------------------- 

226 

227 

228class ScriptRejected(Exception): 

229 """Raised when a script source contains a disallowed construct. 

230 

231 Mirror of :class:`mcp.mission.predicate.PredicateRejected` so callers 

232 can render uniform structured errors regardless of which sandbox 

233 layer rejected the input. ``reason`` is a short stable token (e.g. 

234 ``"forbidden_node"``, ``"shadows_protected_name"``) suitable for 

235 machine-readable error envelopes; ``failing_node`` is the 

236 :class:`ast.AST` that triggered rejection (``None`` only when the 

237 source failed to parse at all). 

238 """ 

239 

240 def __init__( 

241 self, 

242 reason: str, 

243 *, 

244 failing_node: ast.AST | None = None, 

245 message: str | None = None, 

246 ) -> None: 

247 self.reason: str = reason 

248 self.failing_node: ast.AST | None = failing_node 

249 self.lineno: int | None = ( 

250 getattr(failing_node, "lineno", None) if failing_node is not None else None 

251 ) 

252 self.col_offset: int | None = ( 

253 getattr(failing_node, "col_offset", None) if failing_node is not None else None 

254 ) 

255 rendered = message if message is not None else reason 

256 if self.lineno is not None: 

257 rendered = f"{rendered} (line {self.lineno}, col {self.col_offset})" 

258 super().__init__(rendered) 

259 

260 

261# --------------------------------------------------------------------------- 

262# Validator 

263# --------------------------------------------------------------------------- 

264 

265 

266class _ScriptValidator(ast.NodeVisitor): 

267 """Walk a script AST and reject any construct outside the allowlist. 

268 

269 The validator tracks two things across the walk: 

270 

271 * **The base scope** — the union of the operator-supplied tool 

272 allowlist, the safe builtins, the allowed exception names, and 

273 the ``mission`` namespace. These names are *protected*: a script 

274 may read them but may not bind, rebind, or shadow them with a 

275 local of any kind (assignment, walrus, function parameter, 

276 function name, comprehension target, ``for`` target, 

277 ``except as`` name). Protecting them keeps the security model 

278 one-line-tall: if you see a Name in the source whose ``id`` is 

279 ``submit_job_sqs``, you can be sure it resolves to the registered 

280 tool. 

281 * **A scope stack** — entries onto the stack carry the names a 

282 script has bound at module level plus the names introduced by 

283 function parameters, comprehension targets, ``for`` loops, and 

284 ``except as`` clauses. The stack is what makes a helper function 

285 that defines a parameter ``i`` validate cleanly without ``i`` 

286 leaking into the module-level scope. 

287 """ 

288 

289 def __init__(self, allowlist: Iterable[str]) -> None: 

290 # Order does not matter; keep as a frozenset for fast membership. 

291 self._tool_allowlist: frozenset[str] = frozenset(allowlist) 

292 

293 # Names that are visible from the start of the script and that 

294 # script-introduced bindings may NOT shadow. The mission 

295 # namespace counts as protected: rebinding it would defeat the 

296 # one-allowed-attribute-base rule in :meth:`visit_Attribute`. 

297 # The forbidden call targets (``eval``, ``exec``, ``compile``, 

298 # ``__import__``) are folded into the protected set so that a 

299 # script trying to shadow them — ``(eval := 1)``, ``def exec(): 

300 # ...``, ``for compile in xs:`` — is rejected at the binding 

301 # site with ``shadows_protected_name``, in addition to the 

302 # call-site rejection in :meth:`visit_Call`. Two layers of 

303 # defense for the same risk: a reader does not have to chase 

304 # every later use to know whether the shadow is harmful. 

305 self._base_scope: frozenset[str] = ( 

306 self._tool_allowlist 

307 | _SAFE_BUILTINS 

308 | _ALLOWED_EXCEPTION_NAMES 

309 | _FORBIDDEN_CALL_TARGETS 

310 | {_MISSION_NAMESPACE_NAME} 

311 ) 

312 

313 # Stack of frozensets of script-bound names (function params, 

314 # for-loop targets, comprehension targets, assignment targets, 

315 # function definitions). The base frame is empty; each scope 

316 # push appends a new frame whose contents accumulate from the 

317 # parent frame so a nested lookup can see outer locals. 

318 self._scopes: list[frozenset[str]] = [frozenset()] 

319 

320 # ---- helpers ------------------------------------------------------- 

321 

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

323 return self._scopes[-1] 

324 

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

326 return name in self._base_scope or name in self._current_locals() 

327 

328 @staticmethod 

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

330 return name.startswith("__") 

331 

332 @staticmethod 

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

334 raise ScriptRejected(reason, failing_node=node, message=message) 

335 

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

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

338 

339 def _pop_scope(self) -> None: 

340 self._scopes.pop() 

341 

342 def _bind_local(self, name: str, node: ast.AST) -> None: 

343 """Add ``name`` to the current frame, rejecting protected shadows. 

344 

345 Used by every binding form (assignment, walrus, function name, 

346 function parameter, ``for`` target, comprehension target, 

347 ``except as`` name). The shadow check is what prevents a 

348 script from rebinding ``submit_job_sqs`` or ``mission`` and 

349 thereby sneaking past later name-based validation. 

350 """ 

351 if self._is_dunder(name): 

352 self._reject( 

353 "dunder_binding", 

354 node, 

355 f"binding to '{name}' is not allowed (starts with '__')", 

356 ) 

357 if name in self._base_scope: 

358 self._reject( 

359 "shadows_protected_name", 

360 node, 

361 f"binding to '{name}' shadows a protected name", 

362 ) 

363 # The accumulated-frame model means we replace the top frame 

364 # rather than mutate it in place: every ``_push_scope`` already 

365 # captured the parent, and append-adds at the leaf are local to 

366 # this frame. 

367 self._scopes[-1] = self._scopes[-1] | {name} 

368 

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

370 """Flatten an assignment / for / comprehension target. 

371 

372 Tuples and lists nest (``for (a, b) in pairs``). ``Starred`` 

373 wraps (``a, *rest = xs``). Anything else under a target — 

374 ``Subscript``, ``Attribute`` — would be a write into a 

375 non-local namespace and is rejected by the caller via the 

376 ``invalid_target`` reason. 

377 """ 

378 if isinstance(target, ast.Name): 

379 return [target] 

380 if isinstance(target, (ast.Tuple, ast.List)): 

381 collected: list[ast.Name] = [] 

382 for elt in target.elts: 

383 collected.extend(self._collect_target_names(elt)) 

384 return collected 

385 if isinstance(target, ast.Starred): 

386 return self._collect_target_names(target.value) 

387 self._reject( 

388 "invalid_target", 

389 target, 

390 "assignment / loop target must be a plain identifier", 

391 ) 

392 return [] # unreachable; _reject raises 

393 

394 def _bind_targets(self, target: ast.AST) -> None: 

395 for name_node in self._collect_target_names(target): 

396 self._bind_local(name_node.id, name_node) 

397 

398 # ---- top-level entry ---------------------------------------------- 

399 

400 def visit_Module(self, node: ast.Module) -> None: 

401 # ``ast.parse(..., mode="exec")`` produces a Module whose body 

402 # is a list of statements. Walk each in order so any forward 

403 # binding (e.g. a function definition followed by a call) 

404 # validates with the binding visible in the same module scope. 

405 for stmt in node.body: 

406 self.visit(stmt) 

407 

408 # ---- catch-all ----------------------------------------------------- 

409 

410 def generic_visit(self, node: ast.AST) -> None: 

411 # Default rejection: the validator opts in to every supported 

412 # node via a dedicated ``visit_*`` method. Anything reaching 

413 # ``generic_visit`` is something the operator wrote that the 

414 # script surface deliberately does not support — ``Import``, 

415 # ``ClassDef``, ``Global``, ``Nonlocal``, ``Match``, ``With``, 

416 # ``Assert``, ``Delete``, ``Yield``, ``AsyncFunctionDef`` / 

417 # ``AsyncFor`` / ``AsyncWith`` (``Await`` is handled by its 

418 # own narrow visitor), etc. 

419 self._reject( 

420 "forbidden_node", 

421 node, 

422 f"{type(node).__name__} is not allowed in a script", 

423 ) 

424 

425 # ---- statements ---------------------------------------------------- 

426 

427 def visit_Expr(self, node: ast.Expr) -> None: 

428 self.visit(node.value) 

429 

430 def visit_Pass(self, node: ast.Pass) -> None: 

431 # No children; the visitor still has to opt in to keep 

432 # generic_visit from rejecting it. 

433 pass 

434 

435 def visit_Break(self, node: ast.Break) -> None: 

436 pass 

437 

438 def visit_Continue(self, node: ast.Continue) -> None: 

439 pass 

440 

441 def visit_Assign(self, node: ast.Assign) -> None: 

442 # Validate the RHS *first* under the current scope, then bind 

443 # the LHS targets. This ordering matters for ``x = x + 1``: the 

444 # right-hand ``x`` must already exist as a local; if it does 

445 # not, the ``visit_Name`` lookup fails. Conversely, ``x = 1`` 

446 # introduces ``x`` only after the literal validates. 

447 self.visit(node.value) 

448 for target in node.targets: 

449 self._bind_targets(target) 

450 

451 def visit_AugAssign(self, node: ast.AugAssign) -> None: 

452 if not isinstance(node.op, _ALLOWED_BIN_OPS): 452 ↛ 453line 452 didn't jump to line 453 because the condition on line 452 was never true

453 self._reject( 

454 "binop_not_allowed", 

455 node, 

456 f"augmented operator {type(node.op).__name__} is not allowed", 

457 ) 

458 # ``x += 1`` reads ``x`` then writes ``x``. The target Name 

459 # must be visible already (no defining via aug-assign), and 

460 # the target itself must not be a protected name. We re-use 

461 # ``_bind_local`` for the shadow check; if ``x`` is already 

462 # local the bind is a no-op. 

463 if not isinstance(node.target, ast.Name): 

464 self._reject( 

465 "invalid_target", 

466 node.target, 

467 "augmented assignment target must be a plain identifier", 

468 ) 

469 # Read-side check: target must already be in scope. 

470 self.visit(node.target) 

471 self.visit(node.value) 

472 # Bind defensively — protects against aug-assign on a 

473 # protected name even though the read-side visit above would 

474 # already accept it (protected names ARE visible). The 

475 # shadow check fires here. 

476 self._bind_local(node.target.id, node.target) 

477 

478 def visit_AnnAssign(self, node: ast.AnnAssign) -> None: 

479 # ``x: int = 1`` and ``x: int`` are accepted; ``obj.attr: int`` 

480 # is not (target must be a plain identifier). 

481 if node.value is not None: 481 ↛ 483line 481 didn't jump to line 483 because the condition on line 481 was always true

482 self.visit(node.value) 

483 if node.annotation is not None: 483 ↛ 485line 483 didn't jump to line 485 because the condition on line 483 was always true

484 self.visit(node.annotation) 

485 if not isinstance(node.target, ast.Name): 

486 self._reject( 

487 "invalid_target", 

488 node.target, 

489 "annotated assignment target must be a plain identifier", 

490 ) 

491 self._bind_local(node.target.id, node.target) 

492 

493 def visit_If(self, node: ast.If) -> None: 

494 self.visit(node.test) 

495 for stmt in node.body: 

496 self.visit(stmt) 

497 for stmt in node.orelse: 497 ↛ 498line 497 didn't jump to line 498 because the loop on line 497 never started

498 self.visit(stmt) 

499 

500 def visit_While(self, node: ast.While) -> None: 

501 self.visit(node.test) 

502 for stmt in node.body: 

503 self.visit(stmt) 

504 for stmt in node.orelse: 

505 self.visit(stmt) 

506 

507 def visit_For(self, node: ast.For) -> None: 

508 # Validate the iterable in the *outer* scope, then bind the 

509 # loop targets in the same scope as the body. ``for x in xs:`` 

510 # leaks ``x`` after the loop, matching Python semantics. 

511 self.visit(node.iter) 

512 self._bind_targets(node.target) 

513 for stmt in node.body: 

514 self.visit(stmt) 

515 for stmt in node.orelse: 

516 self.visit(stmt) 

517 

518 def visit_Return(self, node: ast.Return) -> None: 

519 if node.value is not None: 519 ↛ exitline 519 didn't return from function 'visit_Return' because the condition on line 519 was always true

520 self.visit(node.value) 

521 

522 def visit_Raise(self, node: ast.Raise) -> None: 

523 if node.exc is not None: 523 ↛ 525line 523 didn't jump to line 525 because the condition on line 523 was always true

524 self.visit(node.exc) 

525 if node.cause is not None: 525 ↛ 526line 525 didn't jump to line 526 because the condition on line 525 was never true

526 self.visit(node.cause) 

527 

528 def visit_Try(self, node: ast.Try) -> None: 

529 # Body of the try block runs in the current scope. 

530 for stmt in node.body: 

531 self.visit(stmt) 

532 for handler in node.handlers: 

533 # Bare ``except:`` is rejected — operators must name the 

534 # exception class so an unrelated bug is not silently 

535 # swallowed by the same handler that catches a tool 

536 # timeout. 

537 if handler.type is None: 

538 self._reject( 

539 "bare_except", 

540 handler, 

541 "bare 'except:' is not allowed; name the exception class", 

542 ) 

543 self.visit(handler.type) 

544 # ``except Exc as name:`` introduces ``name`` only inside 

545 # the handler block, mirroring Python semantics. Push a 

546 # new scope so the binding does not leak to siblings. 

547 self._push_scope(frozenset()) 

548 try: 

549 if handler.name is not None: 

550 # ``handler`` is the canonical AST node for the 

551 # binding location; reuse it as the failing-node 

552 # context for shadow rejections. 

553 self._bind_local(handler.name, handler) 

554 for stmt in handler.body: 

555 self.visit(stmt) 

556 finally: 

557 self._pop_scope() 

558 for stmt in node.orelse: 

559 self.visit(stmt) 

560 for stmt in node.finalbody: 

561 self.visit(stmt) 

562 

563 def visit_FunctionDef(self, node: ast.FunctionDef) -> None: 

564 # Decorators are gated by a dedicated allowlist so the security 

565 # surface stays small. The list is currently empty. 

566 for deco in node.decorator_list: 

567 if not (isinstance(deco, ast.Name) and deco.id in _ALLOWED_DECORATORS): 567 ↛ 566line 567 didn't jump to line 566 because the condition on line 567 was always true

568 self._reject( 

569 "decorator_not_allowed", 

570 deco, 

571 "decorators are not allowed on script functions", 

572 ) 

573 # Bind the function name in the *current* scope so the rest of 

574 # the module can call it. The body opens a new scope under 

575 # which arguments live. 

576 self._bind_local(node.name, node) 

577 self._validate_function_signature_and_body(node.args, node.body, node) 

578 

579 def _validate_function_signature_and_body( 

580 self, 

581 args: ast.arguments, 

582 body: list[ast.stmt], 

583 owner: ast.AST, 

584 ) -> None: 

585 # No defaults that touch the outer scope are forbidden, but 

586 # the default expressions still validate under the *outer* 

587 # scope (Python evaluates them once at def time, not per call). 

588 for default in args.defaults: 

589 self.visit(default) 

590 for kw_default in args.kw_defaults: 

591 if kw_default is not None: 

592 self.visit(kw_default) 

593 

594 # Collect parameter names. Reject duplicates and protected 

595 # shadows up front so the body sees a coherent local frame. 

596 param_names: list[tuple[str, ast.AST]] = [] 

597 

598 def _collect_arg(arg: ast.arg) -> None: 

599 param_names.append((arg.arg, arg)) 

600 if arg.annotation is not None: 600 ↛ 601line 600 didn't jump to line 601 because the condition on line 600 was never true

601 self.visit(arg.annotation) 

602 

603 for arg in args.posonlyargs: 603 ↛ 604line 603 didn't jump to line 604 because the loop on line 603 never started

604 _collect_arg(arg) 

605 for arg in args.args: 

606 _collect_arg(arg) 

607 if args.vararg is not None: 

608 _collect_arg(args.vararg) 

609 for arg in args.kwonlyargs: 

610 _collect_arg(arg) 

611 if args.kwarg is not None: 

612 _collect_arg(args.kwarg) 

613 

614 # Push a fresh frame; bindings inside the function do not 

615 # leak to the module-level scope. 

616 self._push_scope(frozenset()) 

617 try: 

618 seen: set[str] = set() 

619 for name, owning_node in param_names: 

620 if name in seen: 

621 self._reject( 

622 "duplicate_parameter", 

623 owning_node, 

624 f"duplicate parameter '{name}'", 

625 ) 

626 seen.add(name) 

627 self._bind_local(name, owning_node) 

628 for stmt in body: 

629 self.visit(stmt) 

630 finally: 

631 self._pop_scope() 

632 

633 # ---- expressions --------------------------------------------------- 

634 

635 def visit_Constant(self, node: ast.Constant) -> None: 

636 # Reject dunder strings even when used as plain data. The same 

637 # rationale as in the predicate sandbox: a string like 

638 # ``"__class__"`` only ever appears in source code as part of 

639 # an introspection escape pattern (``getattr(x, "__class__")``, 

640 # ``locals()["__import__"]``). Forbidding them at the constant 

641 # level closes those off even if a future change widened the 

642 # call or attribute allowlist. 

643 if isinstance(node.value, str) and self._is_dunder(node.value): 

644 self._reject( 

645 "dunder_string", 

646 node, 

647 "string constants starting with '__' are not allowed", 

648 ) 

649 

650 def visit_Name(self, node: ast.Name) -> None: 

651 if self._is_dunder(node.id): 651 ↛ 652line 651 didn't jump to line 652 because the condition on line 651 was never true

652 self._reject( 

653 "dunder_name", 

654 node, 

655 f"identifier '{node.id}' starts with '__'", 

656 ) 

657 if not self._name_is_visible(node.id): 

658 self._reject( 

659 "name_not_allowed", 

660 node, 

661 f"name '{node.id}' is not in the script allowlist", 

662 ) 

663 

664 def visit_NamedExpr(self, node: ast.NamedExpr) -> None: 

665 # ``(x := expr)`` — the walrus binds ``x`` in the enclosing 

666 # scope. Validate the value first, then route through the 

667 # standard binding helper so the protected-name shadow check 

668 # fires for ``(mission := ...)`` etc. 

669 self.visit(node.value) 

670 if not isinstance(node.target, ast.Name): 670 ↛ 671line 670 didn't jump to line 671 because the condition on line 670 was never true

671 self._reject( 

672 "invalid_target", 

673 node.target, 

674 "walrus target must be a plain identifier", 

675 ) 

676 self._bind_local(node.target.id, node.target) 

677 

678 def visit_Lambda(self, node: ast.Lambda) -> None: 

679 # Lambdas are scoped expressions: validate parameters + body 

680 # under a fresh frame, exactly like a ``FunctionDef`` minus 

681 # the decorator list and statement body. The lambda itself 

682 # produces no binding in the enclosing scope. 

683 self._validate_function_signature_and_body(node.args, [ast.Expr(value=node.body)], node) 

684 

685 # ---- containers ---------------------------------------------------- 

686 

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

688 for elt in node.elts: 

689 self.visit(elt) 

690 

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

692 for elt in node.elts: 

693 self.visit(elt) 

694 

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

696 for elt in node.elts: 

697 self.visit(elt) 

698 

699 def visit_Dict(self, node: ast.Dict) -> None: 

700 for key in node.keys: 

701 if key is not None: 

702 self.visit(key) 

703 else: 

704 # ``{**other}`` would let a script splat arbitrary 

705 # mappings into a dict literal; reject for the same 

706 # reason as in the predicate sandbox. 

707 self._reject( 

708 "dict_unpacking", 

709 node, 

710 "dict unpacking is not allowed in a script", 

711 ) 

712 for value in node.values: 

713 self.visit(value) 

714 

715 def visit_Starred(self, node: ast.Starred) -> None: 

716 # ``[*xs]``, ``f(*xs)``, ``a, *rest = xs`` — recurse into the 

717 # inner expression so the nested Name still hits the 

718 # allowlist check. 

719 self.visit(node.value) 

720 

721 # ---- operators ----------------------------------------------------- 

722 

723 def visit_BinOp(self, node: ast.BinOp) -> None: 

724 if not isinstance(node.op, _ALLOWED_BIN_OPS): 724 ↛ 725line 724 didn't jump to line 725 because the condition on line 724 was never true

725 self._reject( 

726 "binop_not_allowed", 

727 node, 

728 f"binary operator {type(node.op).__name__} is not allowed", 

729 ) 

730 self.visit(node.left) 

731 self.visit(node.right) 

732 

733 def visit_UnaryOp(self, node: ast.UnaryOp) -> None: 

734 if not isinstance(node.op, _ALLOWED_UNARY_OPS): 

735 self._reject( 

736 "unaryop_not_allowed", 

737 node, 

738 f"unary operator {type(node.op).__name__} is not allowed", 

739 ) 

740 self.visit(node.operand) 

741 

742 def visit_BoolOp(self, node: ast.BoolOp) -> None: 

743 if not isinstance(node.op, _ALLOWED_BOOL_OPS): 

744 self._reject( 

745 "boolop_not_allowed", 

746 node, 

747 f"bool operator {type(node.op).__name__} is not allowed", 

748 ) 

749 for value in node.values: 

750 self.visit(value) 

751 

752 def visit_Compare(self, node: ast.Compare) -> None: 

753 for op in node.ops: 

754 if not isinstance(op, _ALLOWED_COMPARE_OPS): 754 ↛ 755line 754 didn't jump to line 755 because the condition on line 754 was never true

755 self._reject( 

756 "compareop_not_allowed", 

757 node, 

758 f"comparison operator {type(op).__name__} is not allowed", 

759 ) 

760 self.visit(node.left) 

761 for comparator in node.comparators: 

762 self.visit(comparator) 

763 

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

765 self.visit(node.test) 

766 self.visit(node.body) 

767 self.visit(node.orelse) 

768 

769 # ---- attribute and subscript -------------------------------------- 

770 

771 def visit_Attribute(self, node: ast.Attribute) -> None: 

772 # The script surface allows attribute access on exactly one 

773 # name — the ``mission`` namespace — and only for the two 

774 # helper attributes ``observe`` and ``event``. Every other 

775 # ``foo.bar`` reads raise ``ScriptRejected``: tool results are 

776 # opaque values, not deep object graphs, so a script that 

777 # needs nested data should use subscripting on a return value. 

778 if self._is_dunder(node.attr): 

779 self._reject( 

780 "dunder_attribute", 

781 node, 

782 f"attribute '{node.attr}' starts with '__'", 

783 ) 

784 if not isinstance(node.value, ast.Name): 

785 self._reject( 

786 "attribute_target_not_name", 

787 node, 

788 "attribute access is only allowed on the 'mission' namespace", 

789 ) 

790 if node.value.id != _MISSION_NAMESPACE_NAME: 

791 self._reject( 

792 "attribute_target_not_allowed", 

793 node, 

794 "attribute access is only allowed on the 'mission' namespace", 

795 ) 

796 if node.attr not in _MISSION_HELPER_ATTRIBUTES: 

797 self._reject( 

798 "attribute_not_allowed", 

799 node, 

800 f"'mission.{node.attr}' is not an allowed helper", 

801 ) 

802 # ``mission`` itself is a base-scope name; visit it for 

803 # regularity so any future Name-side check still fires here. 

804 self.visit(node.value) 

805 

806 def visit_Subscript(self, node: ast.Subscript) -> None: 

807 # Recurse into both the value and the slice. The base of the 

808 # chain falls out as a ``Name`` lookup that hits the 

809 # allowlist; slices may themselves contain Names and Calls 

810 # that go through the same validation path. 

811 self.visit(node.value) 

812 self.visit(node.slice) 

813 

814 def visit_Slice(self, node: ast.Slice) -> None: 

815 if node.lower is not None: 

816 self.visit(node.lower) 

817 if node.upper is not None: 

818 self.visit(node.upper) 

819 if node.step is not None: 

820 self.visit(node.step) 

821 

822 # ---- calls --------------------------------------------------------- 

823 

824 def visit_Call(self, node: ast.Call) -> None: 

825 # The callee form decides which rule applies. Three shapes are 

826 # allowed: 

827 # 

828 # * ``name(...)`` — bare name call. The name must already be 

829 # visible (base-scope or script-bound local). 

830 # * ``mission.observe(...)`` / ``mission.event(...)`` — the 

831 # only attribute-call shape supported. 

832 # 

833 # ``foo()()`` (call returning a callable, then call), ``a[0]()`` 

834 # (subscript-then-call), and ``x.y()`` for any ``y`` not on the 

835 # mission helper list are all rejected outright. 

836 func = node.func 

837 if isinstance(func, ast.Name): 

838 # ``__import__``, ``exec``, ``eval``, ``compile`` are 

839 # rejected by name even if a script defined a local with 

840 # one of those names. The dunder filter in 

841 # :meth:`visit_Name` already rejects ``__import__`` for 

842 # plain reads; the explicit list is what blocks the 

843 # ``def exec(): ...; exec()`` shadow attempt. 

844 if func.id in _FORBIDDEN_CALL_TARGETS: 

845 self._reject( 

846 "forbidden_call_target", 

847 node, 

848 f"call to '{func.id}' is not allowed", 

849 ) 

850 # Visit the Name so the visibility / dunder check fires. 

851 self.visit(func) 

852 elif isinstance(func, ast.Attribute): 

853 # Only ``mission.observe`` / ``mission.event``. The 

854 # attribute visit raises with a structured reason for 

855 # every other shape (non-Name base, non-mission base, 

856 # disallowed attribute), so we just recurse here. 

857 self.visit(func) 

858 else: 

859 # ``f()()``, ``xs[0]()``, ``(lambda: ...)()`` — the 

860 # callee is neither a Name nor a single ``mission.<x>`` 

861 # attribute access. Reject without descending; the 

862 # blanket ``call_target_shape`` reason captures all three. 

863 self._reject( 

864 "call_target_shape", 

865 node, 

866 "script calls must target a bare name or 'mission.<helper>'", 

867 ) 

868 for arg in node.args: 

869 self.visit(arg) 

870 for kw in node.keywords: 

871 # ``**kwargs`` shows up as a keyword with arg=None; allow 

872 # the value but recurse so its content is still validated 

873 # against the same name and call rules. 

874 self.visit(kw.value) 

875 

876 def visit_Await(self, node: ast.Await) -> None: 

877 # The runtime layer (:class:`MissionSandbox`) exposes every 

878 # allowlisted tool through Monty's ``external_functions`` 

879 # channel, where a registered async callable surfaces inside 

880 # the script as a coroutine factory. Calling 

881 # ``find_examples(query="gpu")`` from inside a script returns 

882 # a coroutine object, not the dispatcher's return value; 

883 # consuming the value requires writing ``await 

884 # find_examples(query="gpu")``. The two ``mission`` helpers 

885 # ride the same channel — the runtime layer prepends a small 

886 # source-level shim that makes ``mission.observe`` / 

887 # ``mission.event`` route into host-side closures via the same 

888 # coroutine-factory channel, so awaiting them is required for 

889 # the side effect (an observation row, an event row) to land 

890 # on the iteration's audit log. The validator therefore opens 

891 # ``Await`` for exactly two shapes: 

892 # 

893 # * ``await <name>(...)`` where ``<name>`` is on the per- 

894 # session tool allowlist. 

895 # * ``await mission.observe(...)`` / ``await mission.event(...)`` 

896 # — attribute calls on the ``mission`` namespace whose 

897 # attribute is one of the two helper names that 

898 # :meth:`visit_Attribute` already accepts. 

899 # 

900 # Both forms route the wrapped Call back through 

901 # :meth:`visit_Call` so kwargs, positional args, and the 

902 # forbidden-call-target rules apply unchanged. 

903 # 

904 # Rejected (folded into ``await_not_allowed``): 

905 # 

906 # * ``await x`` — bare name (no Call inside). 

907 # * ``await some_other_tool()`` — call on a Name that is not 

908 # on the per-session tool allowlist (a safe builtin, an 

909 # exception class, ``mission`` itself, a script-bound local, 

910 # or simply unknown). 

911 # * ``await mission.foo(...)`` for any ``foo`` outside the 

912 # helper set — :meth:`visit_Attribute` would already reject 

913 # the inner call, but the early reject here keeps the reason 

914 # token stable as ``await_not_allowed``. 

915 # * ``await x.observe(...)`` for any ``x`` other than 

916 # ``mission`` — same rationale. 

917 # * ``await (lambda: ...)()`` / ``await xs[0]()`` — 

918 # subscript-then-call / call-of-call shapes; the underlying 

919 # Call would already fail :meth:`visit_Call`'s 

920 # ``call_target_shape`` check, but reject at the await 

921 # level too so the reason token stays ``await_not_allowed``. 

922 # 

923 # ``AsyncFunctionDef`` / ``AsyncFor`` / ``AsyncWith`` continue 

924 # to fall through to :meth:`generic_visit` and stay rejected 

925 # with ``forbidden_node`` — the relaxation here covers only 

926 # the bare ``Await`` expression on the two accepted call 

927 # shapes. 

928 inner = node.value 

929 if not isinstance(inner, ast.Call): 929 ↛ 930line 929 didn't jump to line 930 because the condition on line 929 was never true

930 self._reject( 

931 "await_not_allowed", 

932 node, 

933 "'await' may only be used on a call to an allowlisted " 

934 "tool or a 'mission.<helper>' call", 

935 ) 

936 func = inner.func 

937 if isinstance(func, ast.Name): 

938 if func.id not in self._tool_allowlist: 938 ↛ 939line 938 didn't jump to line 939 because the condition on line 938 was never true

939 self._reject( 

940 "await_not_allowed", 

941 node, 

942 "'await' may only be used on a call to an allowlisted " 

943 "tool or a 'mission.<helper>' call", 

944 ) 

945 elif isinstance(func, ast.Attribute): 945 ↛ 959line 945 didn't jump to line 959 because the condition on line 945 was always true

946 # Only ``mission.observe(...)`` / ``mission.event(...)``. 

947 if not ( 947 ↛ 952line 947 didn't jump to line 952 because the condition on line 947 was never true

948 isinstance(func.value, ast.Name) 

949 and func.value.id == _MISSION_NAMESPACE_NAME 

950 and func.attr in _MISSION_HELPER_ATTRIBUTES 

951 ): 

952 self._reject( 

953 "await_not_allowed", 

954 node, 

955 "'await' may only be used on a call to an allowlisted " 

956 "tool or a 'mission.<helper>' call", 

957 ) 

958 else: 

959 self._reject( 

960 "await_not_allowed", 

961 node, 

962 "'await' may only be used on a call to an allowlisted " 

963 "tool or a 'mission.<helper>' call", 

964 ) 

965 # Hand the Call node back to the existing call-validation 

966 # machinery so kwargs, positional args, and the 

967 # forbidden-call-target check all fire exactly as they would 

968 # for the non-awaited form. 

969 self.visit(inner) 

970 

971 # ---- f-strings ----------------------------------------------------- 

972 

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

974 for value in node.values: 

975 self.visit(value) 

976 

977 def visit_FormattedValue(self, node: ast.FormattedValue) -> None: 

978 self.visit(node.value) 

979 if node.format_spec is not None: 979 ↛ 980line 979 didn't jump to line 980 because the condition on line 979 was never true

980 self.visit(node.format_spec) 

981 

982 # ---- comprehensions ----------------------------------------------- 

983 

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

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

986 

987 Each generator's ``iter`` is validated against the *outer* 

988 scope (it cannot reference targets of its own generator), then 

989 the targets are added to the local set so the next generator's 

990 ``ifs`` and any later ``iter`` can see them. Async generators 

991 (``async for``) are rejected; the script body is sync. 

992 """ 

993 accumulated: set[str] = set() 

994 for gen in generators: 

995 if gen.is_async: 995 ↛ 996line 995 didn't jump to line 996 because the condition on line 995 was never true

996 self._reject( 

997 "async_comprehension", 

998 gen.iter, 

999 "async comprehensions are not allowed", 

1000 ) 

1001 self.visit(gen.iter) 

1002 target_names = self._collect_target_names(gen.target) 

1003 for name_node in target_names: 

1004 if self._is_dunder(name_node.id): 

1005 self._reject( 

1006 "dunder_comprehension_target", 

1007 name_node, 

1008 f"comprehension target '{name_node.id}' starts with '__'", 

1009 ) 

1010 if name_node.id in self._base_scope: 

1011 self._reject( 

1012 "shadows_protected_name", 

1013 name_node, 

1014 f"comprehension target '{name_node.id}' shadows a protected name", 

1015 ) 

1016 accumulated.add(name_node.id) 

1017 self._push_scope(frozenset(accumulated)) 

1018 try: 

1019 for if_clause in gen.ifs: 

1020 self.visit(if_clause) 

1021 finally: 

1022 self._pop_scope() 

1023 return frozenset(accumulated) 

1024 

1025 def _visit_comprehension_like( 

1026 self, 

1027 node: ast.ListComp | ast.SetComp | ast.GeneratorExp, 

1028 ) -> None: 

1029 locals_ = self._validate_comprehensions(node.generators) 

1030 self._push_scope(locals_) 

1031 try: 

1032 self.visit(node.elt) 

1033 finally: 

1034 self._pop_scope() 

1035 

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

1037 self._visit_comprehension_like(node) 

1038 

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

1040 self._visit_comprehension_like(node) 

1041 

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

1043 self._visit_comprehension_like(node) 

1044 

1045 def visit_DictComp(self, node: ast.DictComp) -> None: 

1046 locals_ = self._validate_comprehensions(node.generators) 

1047 self._push_scope(locals_) 

1048 try: 

1049 self.visit(node.key) 

1050 self.visit(node.value) 

1051 finally: 

1052 self._pop_scope() 

1053 

1054 

1055# --------------------------------------------------------------------------- 

1056# Public API 

1057# --------------------------------------------------------------------------- 

1058 

1059 

1060def validate_script_ast(script: str, allowlist: list[str]) -> None: 

1061 """Parse and validate a Mission script source string. 

1062 

1063 On success, the function returns ``None`` and the caller may pass 

1064 ``script`` to the sandbox runtime layer. On any disallowed 

1065 construct, raises :class:`ScriptRejected` carrying ``reason``, 

1066 ``failing_node``, ``lineno``, and ``col_offset``. The script is 

1067 *never* executed by this function; it only walks the AST. 

1068 

1069 ``allowlist`` is the per-session list of MCP tool names the script 

1070 may call. Each name becomes a visible bare-Name and a permitted 

1071 call target. Names not in the allowlist (and not in the safe 

1072 builtin / exception / mission set) are rejected at every Name 

1073 lookup. 

1074 """ 

1075 if not isinstance(script, str): 1075 ↛ 1076line 1075 didn't jump to line 1076 because the condition on line 1075 was never true

1076 raise ScriptRejected( 

1077 "not_a_string", 

1078 message="script source must be a str", 

1079 ) 

1080 try: 

1081 parsed = ast.parse(script, mode="exec") 

1082 except SyntaxError as exc: 

1083 rejection = ScriptRejected( 

1084 "syntax_error", 

1085 message=f"could not parse script: {exc.msg}", 

1086 ) 

1087 rejection.lineno = exc.lineno 

1088 rejection.col_offset = exc.offset 

1089 raise rejection from exc 

1090 _ScriptValidator(allowlist).visit(parsed) 

1091 

1092 

1093# =========================================================================== 

1094# Runtime layer — MissionSandbox wrapper around MontySandboxProvider 

1095# =========================================================================== 

1096# 

1097# Where ``validate_script_ast`` above is the parse-time gate, the wrapper 

1098# below is the run-time isolation. A validated script is handed to the 

1099# Monty sandbox under shared duration / memory limits, with two extras 

1100# layered on top: 

1101# 

1102# * The operator-supplied tool allowlist is exposed as a set of async 

1103# callables in the script's namespace. Each callable forwards into the 

1104# engine's tool dispatcher so the existing ``@audit_logged`` / 

1105# feature-flag / allowlist semantics still fire — running inside a 

1106# script is *not* a way to bypass any of those. 

1107# * A ``mission`` namespace object exposes the iteration's read-only 

1108# metadata (deep-copied snapshot of the session's directive, criteria, 

1109# budget, and prior-iteration summaries) plus the two streaming 

1110# helpers ``mission.observe(...)`` / ``mission.event(...)``. The 

1111# helpers append into closure-captured lists that ``MissionSandbox.run`` 

1112# merges into the resulting Observation. 

1113# 

1114# On any limit violation (duration, memory, runtime / typing / syntax 

1115# from inside the script) the ``MontyError`` family bubbles out of the 

1116# provider; the wrapper re-raises it as :class:`SandboxTerminated` 

1117# carrying whatever the script collected before it was killed so the 

1118# engine's ``_decide_phase`` can produce a deterministic ``terminate`` 

1119# verdict with the partial observation attached. 

1120 

1121import copy # noqa: E402 — runtime layer below; keep imports near their consumers 

1122import os # noqa: E402 

1123import time # noqa: E402 

1124from collections.abc import Awaitable, Callable # noqa: E402 

1125from datetime import UTC, datetime # noqa: E402 

1126from types import MappingProxyType # noqa: E402 

1127from typing import Any # noqa: E402 

1128 

1129from . import audit as _audit # noqa: E402 

1130 

1131# --------------------------------------------------------------------------- 

1132# Env helpers — module-level so the constants below are read once at import 

1133# time. Tests pin the constants by monkey-patching the module attributes; a 

1134# per-call read of os.environ would defeat that. 

1135# --------------------------------------------------------------------------- 

1136 

1137 

1138def _int_env(name: str, default: int) -> int: 

1139 """Parse an integer env var; fall back to default on missing/empty/non-numeric. 

1140 

1141 Mirrors the helper in :mod:`mcp.server` so the two code-mode entry 

1142 points read the same caps with the same parsing semantics. Empty, 

1143 whitespace-only, and non-numeric values all collapse to ``default`` 

1144 rather than raising — an operator who fat-fingers the env should 

1145 still get a working sandbox. 

1146 """ 

1147 raw = os.environ.get(name, "").strip() 

1148 if not raw: 

1149 return default 

1150 try: 

1151 return int(raw) 

1152 except ValueError: 

1153 return default 

1154 

1155 

1156def _float_env(name: str, default: float) -> float: 

1157 """Parse a float env var; fall back to default on missing/empty/non-numeric. 

1158 

1159 Same fall-back semantics as :func:`_int_env`. The duration cap is a 

1160 float so fractional seconds remain expressible. 

1161 """ 

1162 raw = os.environ.get(name, "").strip() 

1163 if not raw: 

1164 return default 

1165 try: 

1166 return float(raw) 

1167 except ValueError: 

1168 return default 

1169 

1170 

1171# Read the resource caps once at import time. Tests pin behaviour by 

1172# monkey-patching these module-level constants before constructing a 

1173# MissionSandbox. The defaults match the existing precedent in 

1174# ``gco_mcp/server.py`` where the same env names are wired into the 

1175# Code Mode discovery transform's sandbox. 

1176_DURATION_LIMIT_SECS: float = _float_env("GCO_MCP_CODE_MODE_MAX_DURATION_SECS", 30.0) 

1177_MEMORY_LIMIT_BYTES: int = _int_env("GCO_MCP_CODE_MODE_MAX_MEMORY", 200_000_000) 

1178 

1179 

1180# --------------------------------------------------------------------------- 

1181# Lazy import of the runtime dependencies 

1182# --------------------------------------------------------------------------- 

1183# 

1184# The AST validator above must remain importable on a host where 

1185# ``fastmcp`` and ``pydantic_monty`` are not installed (for example a 

1186# CLI-only environment that runs ``gco mission validate`` against a 

1187# stored session JSON without ever wiring an engine). The provider class 

1188# and the error class are pulled in lazily by ``_import_provider`` and 

1189# cached at module level so repeated MissionSandbox constructions in the 

1190# same process pay the import cost exactly once. 

1191 

1192_MONTY_PROVIDER_CLASS: Any = None 

1193_MONTY_ERROR_CLASS: Any = None 

1194 

1195 

1196def _import_provider() -> tuple[Any, Any]: 

1197 """Lazy-import ``MontySandboxProvider`` and ``MontyError`` and cache them. 

1198 

1199 Returns the ``(provider_cls, error_cls)`` pair. The provider class 

1200 is the value the wrapper instantiates with a ``ResourceLimits`` 

1201 dict; the error class is the *base* ``pydantic_monty.MontyError`` 

1202 that covers the whole limit / runtime / typing / syntax family 

1203 raised from inside a script. We catch the base class rather than 

1204 the leaves so a future Monty release that adds a new error type 

1205 still routes through ``SandboxTerminated`` rather than escaping as 

1206 an opaque ``Exception``. 

1207 """ 

1208 global _MONTY_PROVIDER_CLASS, _MONTY_ERROR_CLASS 

1209 if _MONTY_PROVIDER_CLASS is None: 

1210 from fastmcp.experimental.transforms.code_mode import MontySandboxProvider 

1211 from pydantic_monty import MontyError 

1212 

1213 _MONTY_PROVIDER_CLASS = MontySandboxProvider 

1214 _MONTY_ERROR_CLASS = MontyError 

1215 return _MONTY_PROVIDER_CLASS, _MONTY_ERROR_CLASS 

1216 

1217 

1218# --------------------------------------------------------------------------- 

1219# Termination signal 

1220# --------------------------------------------------------------------------- 

1221 

1222 

1223class SandboxTerminated(Exception): 

1224 """Raised when the Monty sandbox killed the script for exceeding a limit. 

1225 

1226 The Mission engine catches this exception in its decide-phase and 

1227 produces a ``terminate`` verdict for the iteration. Whatever the 

1228 script collected via ``mission.observe(...)`` / ``mission.event(...)`` 

1229 before being killed is carried on the exception so the engine can 

1230 surface the partial Observation in the iteration's audit record — 

1231 a script that ran for 29 seconds and observed five intermediate 

1232 states should not lose those five states just because the 30-second 

1233 cap fired before the script returned. 

1234 

1235 ``cause`` is the underlying Monty exception's class name (e.g. 

1236 ``"MontyRuntimeError"``, ``"MontyTypingError"``) so callers can render 

1237 a stable structured-error envelope without holding a reference to 

1238 the original Monty exception object. 

1239 """ 

1240 

1241 def __init__( 

1242 self, 

1243 cause: str, 

1244 *, 

1245 partial_observations: list[dict[str, Any]] | None = None, 

1246 partial_events: list[dict[str, Any]] | None = None, 

1247 partial_script_call_log: list[dict[str, Any]] | None = None, 

1248 ) -> None: 

1249 self.cause: str = cause 

1250 # Defensive copies: callers occasionally inspect these lists 

1251 # after the exception has propagated several frames up. A 

1252 # shared reference would let a later mutation in the original 

1253 # closure corrupt the audit record. 

1254 self.partial_observations: list[dict[str, Any]] = list(partial_observations or []) 

1255 self.partial_events: list[dict[str, Any]] = list(partial_events or []) 

1256 # Partial in-script tool-call log captured by the per-tool 

1257 # wrappers up to the moment Monty killed the script. Carrying 

1258 # this onto the exception lets the engine's 

1259 # ``_execute_script`` stash the partial calls on the iteration 

1260 # record so a script that fired ten ``submit_job_sqs(...)`` 

1261 # calls before tripping the duration cap still records all ten 

1262 # in the audit log. Defensive copy for the same reason as the 

1263 # observe / event lists above. 

1264 self.partial_script_call_log: list[dict[str, Any]] = list(partial_script_call_log or []) 

1265 super().__init__(f"sandbox terminated: {cause}") 

1266 

1267 

1268# --------------------------------------------------------------------------- 

1269# Script rewrite — mission.observe/event → _mission_observe/_mission_event 

1270# --------------------------------------------------------------------------- 

1271# 

1272# The AST gate above accepts ``mission.observe(...)`` and 

1273# ``mission.event(...)`` as the only two attribute calls a script may 

1274# write on the ``mission`` namespace. The runtime needs those calls to 

1275# land on host-side closures so the iteration's ``observe_log`` / 

1276# ``event_log`` lists actually receive the appends — passing the 

1277# helpers in through ``inputs={"mission": <object>}`` would not work, 

1278# because :class:`MontySandboxProvider` round-trips ``inputs`` values 

1279# into the Monty VM by value (any in-script mutation lands on the VM 

1280# copy, not the host's). Wrapping the helpers in a small host-side 

1281# class and prepending it to the script as a preamble would not work 

1282# either: Monty's parser does not support ``class`` definitions. 

1283# 

1284# Instead, after validation, the host re-parses the script and 

1285# rewrites every accepted ``mission.<helper>(...)`` Call so its 

1286# callee becomes a bare-Name lookup of the corresponding reserved 

1287# external-function name. The rewritten source is then handed to 

1288# Monty, where ``_mission_observe`` / ``_mission_event`` resolve to 

1289# the host-side closures registered via ``external_functions``. 

1290# Operator scripts cannot reference these names directly: the AST 

1291# validator rejects them under ``name_not_allowed`` (neither is on 

1292# the per-session tool allowlist nor in any safe-builtin / exception 

1293# / mission base set), so the only path that produces those Name 

1294# nodes is the rewrite below. 

1295 

1296_MISSION_HELPER_RUNTIME_NAMES: Final[dict[str, str]] = { 

1297 "observe": "_mission_observe", 

1298 "event": "_mission_event", 

1299} 

1300# The keys must mirror ``_MISSION_HELPER_ATTRIBUTES`` exactly: 

1301# the validator opens up ``mission.<attr>`` for those two attributes, 

1302# and the rewriter below has to translate the same two and only the 

1303# same two. A future widening of the helper set has to add an entry 

1304# here too, or the rewriter would leave the new attribute as an 

1305# ``Attribute`` callee and Monty's parser would reject it. 

1306assert set(_MISSION_HELPER_RUNTIME_NAMES) == set(_MISSION_HELPER_ATTRIBUTES) 

1307 

1308 

1309class _MissionAttributeCallRewriter(ast.NodeTransformer): 

1310 """Rewrite ``mission.observe(...)`` / ``mission.event(...)`` callees. 

1311 

1312 The transformer replaces the ``Attribute`` callee on accepted 

1313 ``mission.<helper>`` Call nodes with a ``Name`` referencing the 

1314 corresponding external-function key (``_mission_observe`` / 

1315 ``_mission_event``). Args and kwargs ride through unchanged: the 

1316 AST validator already vetted them, and the rewrite preserves 

1317 source positions so any subsequent error in those subtrees still 

1318 points at the operator's original column. 

1319 

1320 The validator's :meth:`_ScriptValidator.visit_Attribute` already 

1321 rejects every other ``mission.<x>`` shape, so the transformer 

1322 only ever encounters the two helper attributes; defensive 

1323 fallthrough leaves any other ``Attribute`` callee untouched, but 

1324 in practice such a node would not have passed the gate. 

1325 """ 

1326 

1327 def visit_Call(self, node: ast.Call) -> ast.AST: 

1328 # Recurse into args / kwargs first so a nested 

1329 # ``mission.<helper>(...)`` (e.g. inside an f-string used as 

1330 # an argument) is rewritten too. ``self.generic_visit`` 

1331 # walks children and updates them in place. 

1332 self.generic_visit(node) 

1333 func = node.func 

1334 if ( 

1335 isinstance(func, ast.Attribute) 

1336 and isinstance(func.value, ast.Name) 

1337 and func.value.id == _MISSION_NAMESPACE_NAME 

1338 and func.attr in _MISSION_HELPER_RUNTIME_NAMES 

1339 ): 

1340 replacement = ast.Name( 

1341 id=_MISSION_HELPER_RUNTIME_NAMES[func.attr], 

1342 ctx=ast.Load(), 

1343 ) 

1344 ast.copy_location(replacement, func) 

1345 node.func = replacement 

1346 return node 

1347 

1348 

1349def _rewrite_mission_helpers(script: str) -> str: 

1350 """Re-parse ``script``, rewrite mission helper calls, and unparse. 

1351 

1352 Called after :func:`validate_script_ast` has already accepted the 

1353 source — so ``ast.parse`` cannot fail here on syntax that was 

1354 valid moments ago. Returns a fresh source string suitable for 

1355 handing to ``MontySandboxProvider.run``. 

1356 """ 

1357 tree = ast.parse(script, mode="exec") 

1358 rewritten = _MissionAttributeCallRewriter().visit(tree) 

1359 ast.fix_missing_locations(rewritten) 

1360 return ast.unparse(rewritten) 

1361 

1362 

1363# --------------------------------------------------------------------------- 

1364# Tool callable wrapper 

1365# --------------------------------------------------------------------------- 

1366 

1367 

1368def _make_tool_wrapper( 

1369 tool_name: str, 

1370 ctx: Any | None, 

1371 tool_dispatcher: Callable[[str, dict[str, Any], Any], Awaitable[Any]], 

1372 script_call_log: list[dict[str, Any]], 

1373 session_id: str, 

1374 iteration_index: int, 

1375) -> Callable[..., Awaitable[Any]]: 

1376 """Build the per-tool async wrapper inserted into ``external_functions``. 

1377 

1378 The wrapper is keyword-only by design — the Mission script grammar 

1379 passes tool args as kwargs (``submit_job_sqs(manifest_path=..., 

1380 region=...)``) and rejecting positionals at call time keeps the 

1381 wrapper's record shape aligned with the engine's 

1382 :class:`ToolCallRecord`. A script that calls 

1383 ``submit_job_sqs("examples/x.yaml")`` with a positional argument 

1384 fails immediately with a ``TypeError`` from Python's call 

1385 machinery; that error surfaces through Monty as a 

1386 ``MontyRuntimeError`` and is caught by the wrapper layer in 

1387 :meth:`MissionSandbox.run`. 

1388 

1389 The wrapper appends one record to ``script_call_log`` per call, 

1390 whether the call succeeded or raised. A raised exception still 

1391 propagates out of the wrapper (so Monty surfaces it to the script 

1392 as a Python exception the script can catch with 

1393 ``try``/``except``), but the record carries ``status="failed"`` 

1394 plus a truncated error message so the engine's audit path sees 

1395 every invocation. 

1396 

1397 On both success and failure the wrapper also emits a 

1398 ``mission_script_call_event`` audit row tagged 

1399 ``via_script=True``. The dispatch into ``tool_dispatcher`` runs 

1400 the registered tool function, so the standard ``@audit_logged`` 

1401 entry has already fired by the time the wrapper reaches its emit 

1402 site — the script-call event is a *second*, distinct row that 

1403 lets consumers distinguish in-script invocations from direct 

1404 ``tool_calls`` strategy invocations without having to walk 

1405 timestamps. 

1406 """ 

1407 

1408 async def wrapper(**kwargs: Any) -> Any: 

1409 # Snapshot the kwargs into a fresh dict before dispatch so the 

1410 # log entry preserves exactly what the script passed even if 

1411 # the dispatcher mutates the dict downstream. 

1412 args = dict(kwargs) 

1413 started = time.monotonic() 

1414 try: 

1415 result = await tool_dispatcher(tool_name, args, ctx) 

1416 except Exception as exc: 

1417 duration_ms = max(int((time.monotonic() - started) * 1000), 0) 

1418 error_message = f"{type(exc).__name__}: {exc}"[:200] 

1419 script_call_log.append( 

1420 { 

1421 "tool_name": tool_name, 

1422 "args": args, 

1423 "status": "failed", 

1424 "result_summary": None, 

1425 "duration_ms": duration_ms, 

1426 # Truncated to 200 chars to match the audit 

1427 # module's existing convention for error_message 

1428 # fields elsewhere in the engine. 

1429 "error_message": error_message, 

1430 } 

1431 ) 

1432 # Emit the via_script audit row before re-raising so the 

1433 # event is recorded even when the script catches the 

1434 # exception and continues executing. 

1435 _audit.emit_script_call_event( 

1436 session_id, 

1437 iteration_index, 

1438 tool_name, 

1439 "failed", 

1440 duration_ms, 

1441 error_message=error_message, 

1442 ) 

1443 raise 

1444 duration_ms = max(int((time.monotonic() - started) * 1000), 0) 

1445 record: dict[str, Any] = { 

1446 "tool_name": tool_name, 

1447 "args": args, 

1448 "status": "ok", 

1449 "result_summary": result, 

1450 "duration_ms": duration_ms, 

1451 } 

1452 script_call_log.append(record) 

1453 _audit.emit_script_call_event( 

1454 session_id, 

1455 iteration_index, 

1456 tool_name, 

1457 "ok", 

1458 duration_ms, 

1459 ) 

1460 return result 

1461 

1462 # Setting ``__name__`` makes Monty's traceback render the 

1463 # operator's tool name rather than ``wrapper`` when a call goes 

1464 # wrong inside the sandboxed script. The script_call_log remains 

1465 # the canonical record of what fired. 

1466 wrapper.__name__ = tool_name 

1467 return wrapper 

1468 

1469 

1470# --------------------------------------------------------------------------- 

1471# Observation assembly 

1472# --------------------------------------------------------------------------- 

1473 

1474 

1475def _annotate_call_result(call: dict[str, Any]) -> Any: 

1476 """Wrap a script-call ``result_summary`` with per-call markers. 

1477 

1478 Mirrors :meth:`MissionEngine._annotate_tool_result` for the 

1479 scripted-strategy path so the Observation's ``tool_results`` list 

1480 always carries the ``_status`` and ``tool_name`` markers the 

1481 predicate evaluator and the ``tool_call_succeeded`` evaluator 

1482 rely on, regardless of the underlying tool's return shape. 

1483 

1484 Strategy: 

1485 

1486 * **Dict result_summary** — augment in place with ``_status`` and 

1487 ``tool_name`` only when those keys are absent. This keeps any 

1488 caller-supplied marker visible while ensuring evaluators always 

1489 find them. 

1490 * **Non-dict result_summary** — wrap in a fresh dict carrying 

1491 the call's ``_status`` / ``tool_name`` plus a ``result`` field 

1492 that holds the original payload so predicates can still walk 

1493 into it. 

1494 """ 

1495 result = call.get("result_summary") 

1496 status = call.get("status") or "unknown" 

1497 tool_name = call.get("tool_name") 

1498 if isinstance(result, dict): 

1499 annotated = dict(result) 

1500 annotated.setdefault("_status", status) 

1501 annotated.setdefault("tool_name", tool_name) 

1502 return annotated 

1503 return { 

1504 "_status": status, 

1505 "tool_name": tool_name, 

1506 "result": result, 

1507 } 

1508 

1509 

1510def _build_script_observation( 

1511 *, 

1512 script_call_log: list[dict[str, Any]], 

1513 observe_log: list[dict[str, Any]], 

1514 event_log: list[dict[str, Any]], 

1515 phase_started_at: str, 

1516 phase_ended_at: str, 

1517) -> dict[str, Any]: 

1518 """Merge the closure-captured logs into an Observation dict. 

1519 

1520 Mirrors :meth:`MissionEngine._build_observation` for the 

1521 ``tool_calls`` strategy path so a downstream Evaluate_Phase / 

1522 Decide_Phase consumer cannot tell, from the Observation shape 

1523 alone, whether the iteration ran a scripted or a non-scripted 

1524 Strategy: 

1525 

1526 * ``tool_results`` lists every call's ``result_summary`` (including 

1527 failures, for stable indexing against ``script_call_log``). 

1528 * ``metrics`` lifts any top-level ``metrics`` dict from a 

1529 successful tool result, exactly like the engine does. 

1530 * ``events`` pools the events emitted by tool results with the 

1531 ``mission.event(...)`` calls so the criteria evaluator only 

1532 walks one list. 

1533 * ``errors`` carries failed / skipped calls in the same shape the 

1534 engine uses, so the decide-phase heuristic that triggers 

1535 ``adjust`` on new errors keeps working unchanged. 

1536 

1537 The ``mission.observe(...)`` rows fold into a dedicated 

1538 ``observations`` bucket inside ``metrics`` rather than flat-merging 

1539 so a script-collected key cannot silently overwrite a tool-derived 

1540 metric of the same name. A criterion that wants a script-collected 

1541 key reads ``metrics.observations.<key>``; a criterion that wants a 

1542 tool-derived metric reads ``metrics.<key>``. The two namespaces 

1543 stay distinct. 

1544 """ 

1545 tool_results: list[Any] = [] 

1546 metrics: dict[str, Any] = {} 

1547 events: list[dict[str, Any]] = [] 

1548 errors: list[dict[str, Any]] = [] 

1549 

1550 for call in script_call_log: 

1551 tool_results.append(_annotate_call_result(call)) 

1552 if call.get("status") == "ok": 1552 ↛ 1564line 1552 didn't jump to line 1564 because the condition on line 1552 was always true

1553 result = call.get("result_summary") 

1554 if isinstance(result, dict): 1554 ↛ 1555line 1554 didn't jump to line 1555 because the condition on line 1554 was never true

1555 result_metrics = result.get("metrics") 

1556 if isinstance(result_metrics, dict): 

1557 metrics.update(result_metrics) 

1558 result_events = result.get("events") 

1559 if isinstance(result_events, list): 

1560 for event in result_events: 

1561 if isinstance(event, dict): 

1562 events.append(event) 

1563 else: 

1564 errors.append( 

1565 { 

1566 "tool_name": call.get("tool_name"), 

1567 "status": call.get("status"), 

1568 "error_message": call.get("error_message"), 

1569 } 

1570 ) 

1571 

1572 # Pool the script-side ``mission.event(...)`` calls with 

1573 # tool-derived events. ``dict(ev)`` is a defensive copy so a later 

1574 # mutation of the closure list does not bleed into the persisted 

1575 # Observation. 

1576 for ev in event_log: 

1577 events.append(dict(ev)) 

1578 

1579 # ``mission.observe(...)`` rows fold into a dedicated bucket on 

1580 # metrics so they remain addressable without colliding with 

1581 # tool-derived metric names. 

1582 if observe_log: 1582 ↛ 1588line 1582 didn't jump to line 1588 because the condition on line 1582 was always true

1583 observations_bucket: dict[str, Any] = {} 

1584 for entry in observe_log: 

1585 observations_bucket[entry["key"]] = entry["value"] 

1586 metrics["observations"] = observations_bucket 

1587 

1588 observation: dict[str, Any] = { 

1589 "tool_results": tool_results, 

1590 "metrics": metrics, 

1591 "events": events, 

1592 "phase_started_at": phase_started_at, 

1593 "phase_ended_at": phase_ended_at, 

1594 } 

1595 if errors: 1595 ↛ 1596line 1595 didn't jump to line 1596 because the condition on line 1595 was never true

1596 observation["errors"] = errors 

1597 return observation 

1598 

1599 

1600# --------------------------------------------------------------------------- 

1601# MissionSandbox 

1602# --------------------------------------------------------------------------- 

1603 

1604 

1605class MissionSandbox: 

1606 """Run a validated Mission script under ``MontySandboxProvider`` limits. 

1607 

1608 One sandbox per iteration. The constructor freezes the per-iteration 

1609 ``mission`` namespace as a :class:`types.MappingProxyType` snapshot 

1610 (so a script cannot reach back through ``mission`` and mutate the 

1611 session record), pins the operator's tool allowlist, and builds the 

1612 underlying ``MontySandboxProvider`` with the duration / memory 

1613 limits read from the module-level constants. :meth:`run` then 

1614 drives a single script execution end to end: 

1615 

1616 1. AST validate via :func:`validate_script_ast` — propagation of 

1617 :class:`ScriptRejected` is the engine's signal to fail the 

1618 Execute_Phase with reason ``script_rejected``. 

1619 2. Build the ``external_functions`` map: one async wrapper per 

1620 allowlisted tool, each forwarding into the engine's tool 

1621 dispatcher so the wrapper preserves the existing 

1622 ``@audit_logged`` / feature-flag / allowlist semantics — running 

1623 inside a script is *not* a way to bypass any of those. 

1624 3. Execute under Monty's caps. Any ``MontyError`` (limit / 

1625 runtime / typing / syntax) is re-raised as 

1626 :class:`SandboxTerminated` carrying whatever the script 

1627 collected before being killed. 

1628 4. Fold the closure-captured tool log, observe log, and event log 

1629 into an Observation dict whose shape exactly matches the 

1630 engine's tool-calls path. 

1631 

1632 The sandbox is immutable after construction: there are no setters, 

1633 no rebuild methods, and the underlying provider is held by 

1634 reference rather than recreated per call. Each iteration gets its 

1635 own MissionSandbox so a stale frozen namespace cannot leak across 

1636 iterations. 

1637 """ 

1638 

1639 def __init__( 

1640 self, 

1641 allowlist: list[str], 

1642 session: Any, 

1643 ) -> None: 

1644 # Defensive copy of the allowlist: the engine pins the 

1645 # allowlist on the session at create time, but a shared list 

1646 # reference would let later mutations slip past the AST 

1647 # validator's frozenset (which is constructed once per 

1648 # validation call from ``self._allowlist``). 

1649 self._allowlist: list[str] = list(allowlist) 

1650 

1651 # Build the per-iteration mission namespace as an immutable 

1652 # snapshot. Each iteration summary carries only the four 

1653 # fields a script needs to reason about prior progress — 

1654 # full IterationRecord shapes would be both heavy and 

1655 # tempting for a script to walk in ways the engine does not 

1656 # support. 

1657 iteration_summaries: list[dict[str, Any]] = [] 

1658 for it in session.get("iterations") or []: 1658 ↛ 1659line 1658 didn't jump to line 1659 because the loop on line 1658 never started

1659 iteration_summaries.append( 

1660 { 

1661 "iteration_index": it.get("iteration_index"), 

1662 "verdict": it.get("verdict"), 

1663 "verdict_reason": it.get("verdict_reason"), 

1664 "checkpoint_evaluated": it.get("checkpoint_evaluated"), 

1665 } 

1666 ) 

1667 # ``copy.deepcopy`` on criteria + budget so a script that 

1668 # walks them via subscripting cannot mutate the session 

1669 # record even if Python's MappingProxyType were ever 

1670 # bypassed by a future change. 

1671 ns: dict[str, Any] = { 

1672 "session_id": session["session_id"], 

1673 "iteration_index": len(session.get("iterations") or []), 

1674 "directive_text": session.get("directive_text", ""), 

1675 "criteria": copy.deepcopy(session.get("criteria") or []), 

1676 "budget": copy.deepcopy(session.get("budget") or {}), 

1677 "iterations": iteration_summaries, 

1678 } 

1679 self._frozen_mission_ns: MappingProxyType[str, Any] = MappingProxyType(ns) 

1680 

1681 # Construct the provider once and pin it on the instance. 

1682 # The provider holds no per-call state, so reusing it across 

1683 # multiple ``run`` calls would be safe in principle, but the 

1684 # one-sandbox-per-iteration lifetime keeps the failure 

1685 # surface small and matches the rest of the per-iteration 

1686 # state above. 

1687 provider_cls, _ = _import_provider() 

1688 self._provider = provider_cls( 

1689 limits={ 

1690 "max_duration_secs": _DURATION_LIMIT_SECS, 

1691 "max_memory": _MEMORY_LIMIT_BYTES, 

1692 } 

1693 ) 

1694 

1695 # ---- read-only accessors ------------------------------------------ 

1696 

1697 @property 

1698 def frozen_mission_ns(self) -> MappingProxyType[str, Any]: 

1699 """The iteration's frozen ``mission`` namespace snapshot.""" 

1700 return self._frozen_mission_ns 

1701 

1702 @property 

1703 def allowlist(self) -> list[str]: 

1704 """Defensive copy of the per-session tool allowlist.""" 

1705 return list(self._allowlist) 

1706 

1707 # ---- public surface ----------------------------------------------- 

1708 

1709 async def run( 

1710 self, 

1711 script: str, 

1712 ctx: Any | None, 

1713 tool_dispatcher: Callable[[str, dict[str, Any], Any], Awaitable[Any]], 

1714 ) -> tuple[dict[str, Any], list[dict[str, Any]]]: 

1715 """Validate, execute, and observe a Mission script. 

1716 

1717 Returns ``(observation, script_call_log)`` matching the shape 

1718 the engine's ``_execute_script`` expects: the observation is a 

1719 plain dict (engine cast to :class:`Observation` at the call 

1720 site) and the call log is a list of 

1721 :class:`ToolCallRecord`-shaped dicts. 

1722 

1723 On any ``MontyError`` from the provider — duration cap, memory 

1724 cap, runtime / typing / syntax error inside the script — the 

1725 method re-raises as :class:`SandboxTerminated` carrying the 

1726 closure-captured partial observations and events. The engine's 

1727 decide-phase pattern-matches on this exception and produces a 

1728 ``terminate`` verdict for the iteration. 

1729 

1730 ``ScriptRejected`` from the AST validator propagates upward 

1731 unchanged: the engine's Execute_Phase treats that as a 

1732 ``script_rejected`` failure and never reaches the runtime path 

1733 below. 

1734 """ 

1735 # Step 1: AST gate. Propagating ``ScriptRejected`` upward is 

1736 # deliberate — the engine's _execute_phase wraps it as a 

1737 # phase failure with reason ``script_rejected``; doing the 

1738 # rejection here means the runtime path never sees a 

1739 # disallowed source. 

1740 validate_script_ast(script, self._allowlist) 

1741 

1742 _, monty_error_cls = _import_provider() 

1743 

1744 # Closure-captured collectors. Populated synchronously by the 

1745 # host-side helper closures registered as 

1746 # ``external_functions`` and the per-tool wrappers; observed 

1747 # post-run (or post-termination) to build the Observation. 

1748 # Lists rather than dicts so the order in which the script 

1749 # called ``mission.event`` / ``mission.observe`` is preserved 

1750 # in the final record. 

1751 observe_log: list[dict[str, Any]] = [] 

1752 event_log: list[dict[str, Any]] = [] 

1753 script_call_log: list[dict[str, Any]] = [] 

1754 

1755 # Host-side helpers for ``mission.observe`` and 

1756 # ``mission.event``. Routing them through the 

1757 # ``external_functions`` channel — rather than as bound 

1758 # methods on a dataclass shipped via ``inputs`` — is what 

1759 # makes script-side mutations visible to the host: 

1760 # ``MontySandboxProvider`` round-trips ``inputs`` values into 

1761 # the underlying Monty VM by value, so a closure list 

1762 # captured on a method body of an ``inputs`` dataclass would 

1763 # only ever see the VM-side copy. The external-functions 

1764 # channel runs each call back in host Python, so the lists 

1765 # below receive the appends. 

1766 # 

1767 # The signatures match the original ``mission.observe`` / 

1768 # ``mission.event`` script-facing surface: ``observe`` takes 

1769 # ``(key, value)`` positionally, ``event`` takes ``name`` 

1770 # positionally plus arbitrary keyword arguments. The AST 

1771 # rewrite below replaces the attribute callee with a bare 

1772 # Name lookup but leaves args / kwargs unchanged, so the 

1773 # call shape that lands on these helpers is exactly what an 

1774 # operator would write at the script surface. 

1775 async def _mission_observe(key: str, value: Any) -> None: 

1776 observe_log.append({"key": key, "value": value}) 

1777 

1778 async def _mission_event(name: str, **kwargs: Any) -> None: 

1779 event_row: dict[str, Any] = {"event_name": name} 

1780 event_row.update(kwargs) 

1781 event_log.append(event_row) 

1782 

1783 # The frozen mission namespace remains pinned on this 

1784 # sandbox instance (``self._frozen_mission_ns``) so a future 

1785 # widening of the script surface can expose it without 

1786 # rebuilding the construction-time snapshot. It does *not* 

1787 # ride through the ``inputs`` channel today: the validator 

1788 # never accepts attribute access on anything other than 

1789 # ``mission`` (and the only two ``mission`` attributes are 

1790 # the ``observe`` / ``event`` helpers handled by the 

1791 # preamble below), so a script has no way to read the 

1792 # snapshot through Monty's runtime. Holding it on the host 

1793 # side is the simpler shape; routing it as a ``Mapping`` 

1794 # through ``inputs`` would require Monty to convert the 

1795 # full dataclass + nested dicts to its own value model and 

1796 # pay a per-iteration translation cost for data nothing 

1797 # observes. 

1798 

1799 # Build the external_functions mapping. Each tool name maps 

1800 # to an async wrapper; Monty's ``external_functions`` channel 

1801 # auto-wraps sync callables to async, but we register native 

1802 # async functions so the dispatcher's ``await`` chain stays 

1803 # explicit and the wrapper can do its own timing. 

1804 external_functions: dict[str, Callable[..., Any]] = {} 

1805 # Pull the per-iteration identifiers off the frozen namespace 

1806 # snapshot built at construction time so the wrapper records 

1807 # the same ``session_id`` / ``iteration_index`` the rest of 

1808 # the iteration's audit rows carry. 

1809 session_id = self._frozen_mission_ns["session_id"] 

1810 iteration_index = self._frozen_mission_ns["iteration_index"] 

1811 for tool_name in self._allowlist: 

1812 external_functions[tool_name] = _make_tool_wrapper( 

1813 tool_name, 

1814 ctx, 

1815 tool_dispatcher, 

1816 script_call_log, 

1817 session_id, 

1818 iteration_index, 

1819 ) 

1820 

1821 # The two helper functions ride alongside the per-tool 

1822 # wrappers under reserved underscore-prefixed names. Operator 

1823 # scripts cannot collide with these: the AST validator 

1824 # rejects ``_mission_observe`` and ``_mission_event`` as 

1825 # bare names (neither is on the per-session tool allowlist 

1826 # nor any of the safe-builtin / exception / mission base 

1827 # sets), so a script that wrote ``_mission_observe(...)`` 

1828 # directly would fail the gate with ``name_not_allowed``. 

1829 # Only the AST rewrite below — applied *after* the gate — 

1830 # ever produces those Name nodes. 

1831 external_functions["_mission_observe"] = _mission_observe 

1832 external_functions["_mission_event"] = _mission_event 

1833 

1834 # The validated operator source is re-parsed and rewritten 

1835 # so every accepted ``mission.<helper>(...)`` Call's callee 

1836 # becomes a bare-Name lookup of the corresponding reserved 

1837 # external-function name. Monty's parser does not accept 

1838 # ``class`` / nested-attribute shims that would otherwise 

1839 # let us preserve the surface attribute call, so the 

1840 # rewrite happens on the AST itself before the source ever 

1841 # reaches the underlying VM. Operator code keeps its 

1842 # author-time surface (``await mission.observe(key, value)``); 

1843 # only the run-time surface differs. 

1844 final_source = _rewrite_mission_helpers(script) 

1845 

1846 phase_started_at = datetime.now(UTC).isoformat() 

1847 

1848 try: 

1849 await self._provider.run( 

1850 code=final_source, 

1851 inputs={}, 

1852 external_functions=external_functions, 

1853 ) 

1854 except monty_error_cls as exc: 

1855 # ``MontyError`` is the base of the limit / runtime / 

1856 # typing / syntax error family. Catching the base class 

1857 # rather than the leaves means a future Monty release 

1858 # adding a new error type still routes through 

1859 # ``SandboxTerminated`` rather than escaping as an opaque 

1860 # ``Exception``. 

1861 raise SandboxTerminated( 

1862 type(exc).__name__, 

1863 partial_observations=list(observe_log), 

1864 partial_events=list(event_log), 

1865 partial_script_call_log=list(script_call_log), 

1866 ) from exc 

1867 

1868 phase_ended_at = datetime.now(UTC).isoformat() 

1869 

1870 # The script's return value is intentionally ignored: the 

1871 # contract documented for the script surface is "use 

1872 # ``mission.observe(...)`` / ``mission.event(...)`` to report 

1873 # data". A script that returned a dict would conflict with 

1874 # the helper-driven observation list, and the engine's 

1875 # observe-phase already accepts a pre-built Observation 

1876 # without consulting any return value. 

1877 observation = _build_script_observation( 

1878 script_call_log=script_call_log, 

1879 observe_log=observe_log, 

1880 event_log=event_log, 

1881 phase_started_at=phase_started_at, 

1882 phase_ended_at=phase_ended_at, 

1883 ) 

1884 return observation, list(script_call_log) 

1885 

1886 

1887# --------------------------------------------------------------------------- 

1888# Default factory 

1889# --------------------------------------------------------------------------- 

1890 

1891 

1892def make_default_sandbox_runner( 

1893 allowlist: list[str], 

1894 session: Any, 

1895) -> Callable[ 

1896 [str, Any, Callable[[str, dict[str, Any], Any], Awaitable[Any]]], 

1897 Awaitable[tuple[dict[str, Any], list[dict[str, Any]]]], 

1898]: 

1899 """Build the default ``sandbox_runner`` callable for the engine. 

1900 

1901 The :class:`MissionEngine` takes a callable matching the 

1902 ``SandboxRunner`` protocol (``(script, ctx, tool_dispatcher) -> 

1903 (observation_dict, script_call_log)``); this helper wraps a fresh 

1904 :class:`MissionSandbox` for a given session and returns the bound 

1905 :meth:`MissionSandbox.run` method so the engine can drive the 

1906 sandbox without depending on the sandbox class itself. 

1907 

1908 One sandbox per session: the constructor freezes a snapshot of the 

1909 session's directive, criteria, budget, and prior-iteration 

1910 summaries into the ``mission`` namespace, so reusing a runner 

1911 across sessions would leak stale state. The engine's normal 

1912 construction path therefore calls this factory once per 

1913 ``mission_start`` and pins the returned callable on the engine 

1914 instance for the session's lifetime. 

1915 """ 

1916 sandbox = MissionSandbox( 

1917 allowlist=allowlist, 

1918 session=session, 

1919 ) 

1920 return sandbox.run 

1921 

1922 

1923# --------------------------------------------------------------------------- 

1924# Public surface 

1925# --------------------------------------------------------------------------- 

1926 

1927 

1928__all__ = [ 

1929 "MissionSandbox", 

1930 "ScriptRejected", 

1931 "SandboxTerminated", 

1932 "make_default_sandbox_runner", 

1933 "validate_script_ast", 

1934]