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

511 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +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-09-01T14:42:56Z 

100# Generated from Git commit: 89b000378ed5a912a38c06f4feab2b029936ebcc 

101# Flowchart(s) generated from this file: 

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

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

104# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

105# <pyflowchart-code-diagram> END 

106 

107 

108# --------------------------------------------------------------------------- 

109# Allowlists 

110# --------------------------------------------------------------------------- 

111 

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

113 { 

114 "len", 

115 "min", 

116 "max", 

117 "sum", 

118 "abs", 

119 "any", 

120 "all", 

121 "sorted", 

122 "range", 

123 "enumerate", 

124 "zip", 

125 "list", 

126 "dict", 

127 "tuple", 

128 "set", 

129 "str", 

130 "int", 

131 "float", 

132 "bool", 

133 } 

134) 

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

136 

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

138 { 

139 "Exception", 

140 "ValueError", 

141 "TypeError", 

142 "KeyError", 

143 "IndexError", 

144 "AttributeError", 

145 "LookupError", 

146 "RuntimeError", 

147 "ArithmeticError", 

148 "ZeroDivisionError", 

149 "OverflowError", 

150 "OSError", 

151 "FileNotFoundError", 

152 "TimeoutError", 

153 "ConnectionError", 

154 "StopIteration", 

155 "AssertionError", 

156 } 

157) 

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

159 

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

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

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

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

164""" 

165 

166_MISSION_NAMESPACE_NAME: Final[str] = "mission" 

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

168 

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

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

171 

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

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

174) 

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

176 

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

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

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

180""" 

181 

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

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

184 

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

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

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

188""" 

189 

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

191 ast.Add, 

192 ast.Sub, 

193 ast.Mult, 

194 ast.Div, 

195 ast.FloorDiv, 

196 ast.Mod, 

197 ast.Pow, 

198 ast.MatMult, 

199) 

200 

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

202 ast.UAdd, 

203 ast.USub, 

204 ast.Not, 

205 ast.Invert, 

206) 

207 

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

209 ast.Eq, 

210 ast.NotEq, 

211 ast.Lt, 

212 ast.LtE, 

213 ast.Gt, 

214 ast.GtE, 

215 ast.Is, 

216 ast.IsNot, 

217 ast.In, 

218 ast.NotIn, 

219) 

220 

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

222 

223 

224# --------------------------------------------------------------------------- 

225# Exception 

226# --------------------------------------------------------------------------- 

227 

228 

229class ScriptRejected(Exception): 

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

231 

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

233 can render uniform structured errors regardless of which sandbox 

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

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

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

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

238 source failed to parse at all). 

239 """ 

240 

241 def __init__( 

242 self, 

243 reason: str, 

244 *, 

245 failing_node: ast.AST | None = None, 

246 message: str | None = None, 

247 ) -> None: 

248 self.reason: str = reason 

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

250 self.lineno: int | None = ( 

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

252 ) 

253 self.col_offset: int | None = ( 

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

255 ) 

256 rendered = message if message is not None else reason 

257 if self.lineno is not None: 

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

259 super().__init__(rendered) 

260 

261 

262# --------------------------------------------------------------------------- 

263# Validator 

264# --------------------------------------------------------------------------- 

265 

266 

267class _ScriptValidator(ast.NodeVisitor): 

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

269 

270 The validator tracks two things across the walk: 

271 

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

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

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

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

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

277 function name, comprehension target, ``for`` target, 

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

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

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

281 tool. 

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

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

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

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

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

287 leaking into the module-level scope. 

288 """ 

289 

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

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

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

293 

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

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

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

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

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

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

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

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

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

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

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

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

306 self._base_scope: frozenset[str] = ( 

307 self._tool_allowlist 

308 | _SAFE_BUILTINS 

309 | _ALLOWED_EXCEPTION_NAMES 

310 | _FORBIDDEN_CALL_TARGETS 

311 | {_MISSION_NAMESPACE_NAME} 

312 ) 

313 

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

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

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

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

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

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

320 

321 # ---- helpers ------------------------------------------------------- 

322 

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

324 return self._scopes[-1] 

325 

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

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

328 

329 @staticmethod 

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

331 return name.startswith("__") 

332 

333 @staticmethod 

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

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

336 

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

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

339 

340 def _pop_scope(self) -> None: 

341 self._scopes.pop() 

342 

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

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

345 

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

347 function parameter, ``for`` target, comprehension target, 

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

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

350 thereby sneaking past later name-based validation. 

351 """ 

352 if self._is_dunder(name): 

353 self._reject( 

354 "dunder_binding", 

355 node, 

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

357 ) 

358 if name in self._base_scope: 

359 self._reject( 

360 "shadows_protected_name", 

361 node, 

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

363 ) 

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

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

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

367 # this frame. 

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

369 

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

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

372 

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

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

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

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

377 ``invalid_target`` reason. 

378 """ 

379 if isinstance(target, ast.Name): 

380 return [target] 

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

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

383 for elt in target.elts: 

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

385 return collected 

386 if isinstance(target, ast.Starred): 

387 return self._collect_target_names(target.value) 

388 self._reject( 

389 "invalid_target", 

390 target, 

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

392 ) 

393 return [] # unreachable; _reject raises 

394 

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

396 for name_node in self._collect_target_names(target): 

397 self._bind_local(name_node.id, name_node) 

398 

399 # ---- top-level entry ---------------------------------------------- 

400 

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

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

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

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

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

406 for stmt in node.body: 

407 self.visit(stmt) 

408 

409 # ---- catch-all ----------------------------------------------------- 

410 

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

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

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

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

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

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

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

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

419 # own narrow visitor), etc. 

420 self._reject( 

421 "forbidden_node", 

422 node, 

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

424 ) 

425 

426 # ---- statements ---------------------------------------------------- 

427 

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

429 self.visit(node.value) 

430 

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

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

433 # generic_visit from rejecting it. 

434 pass 

435 

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

437 pass 

438 

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

440 pass 

441 

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

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

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

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

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

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

448 self.visit(node.value) 

449 for target in node.targets: 

450 self._bind_targets(target) 

451 

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

453 if not isinstance(node.op, _ALLOWED_BIN_OPS): 

454 self._reject( 

455 "binop_not_allowed", 

456 node, 

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

458 ) 

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

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

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

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

463 # local the bind is a no-op. 

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

465 self._reject( 

466 "invalid_target", 

467 node.target, 

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

469 ) 

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

471 self.visit(node.target) 

472 self.visit(node.value) 

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

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

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

476 # shadow check fires here. 

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

478 

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

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

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

482 if node.value is not None: 

483 self.visit(node.value) 

484 if node.annotation is not None: 

485 self.visit(node.annotation) 

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

487 self._reject( 

488 "invalid_target", 

489 node.target, 

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

491 ) 

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

493 

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

495 self.visit(node.test) 

496 for stmt in node.body: 

497 self.visit(stmt) 

498 for stmt in node.orelse: 

499 self.visit(stmt) 

500 

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

502 self.visit(node.test) 

503 for stmt in node.body: 

504 self.visit(stmt) 

505 for stmt in node.orelse: 

506 self.visit(stmt) 

507 

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

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

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

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

512 self.visit(node.iter) 

513 self._bind_targets(node.target) 

514 for stmt in node.body: 

515 self.visit(stmt) 

516 for stmt in node.orelse: 

517 self.visit(stmt) 

518 

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

520 if node.value is not None: 

521 self.visit(node.value) 

522 

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

524 if node.exc is not None: 

525 self.visit(node.exc) 

526 if node.cause is not None: 

527 self.visit(node.cause) 

528 

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

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

531 for stmt in node.body: 

532 self.visit(stmt) 

533 for handler in node.handlers: 

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

535 # exception class so an unrelated bug is not silently 

536 # swallowed by the same handler that catches a tool 

537 # timeout. 

538 if handler.type is None: 

539 self._reject( 

540 "bare_except", 

541 handler, 

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

543 ) 

544 self.visit(handler.type) 

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

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

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

548 self._push_scope(frozenset()) 

549 try: 

550 if handler.name is not None: 

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

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

553 # context for shadow rejections. 

554 self._bind_local(handler.name, handler) 

555 for stmt in handler.body: 

556 self.visit(stmt) 

557 finally: 

558 self._pop_scope() 

559 for stmt in node.orelse: 

560 self.visit(stmt) 

561 for stmt in node.finalbody: 

562 self.visit(stmt) 

563 

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

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

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

567 for deco in node.decorator_list: 

568 if not (isinstance(deco, ast.Name) and deco.id in _ALLOWED_DECORATORS): 

569 self._reject( 

570 "decorator_not_allowed", 

571 deco, 

572 "decorators are not allowed on script functions", 

573 ) 

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

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

576 # which arguments live. 

577 self._bind_local(node.name, node) 

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

579 

580 def _validate_function_signature_and_body( 

581 self, 

582 args: ast.arguments, 

583 body: list[ast.stmt], 

584 owner: ast.AST, 

585 ) -> None: 

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

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

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

589 for default in args.defaults: 

590 self.visit(default) 

591 for kw_default in args.kw_defaults: 

592 if kw_default is not None: 

593 self.visit(kw_default) 

594 

595 # Collect parameter names. Reject duplicates and protected 

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

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

598 

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

600 param_names.append((arg.arg, arg)) 

601 if arg.annotation is not None: 

602 self.visit(arg.annotation) 

603 

604 for arg in args.posonlyargs: 

605 _collect_arg(arg) 

606 for arg in args.args: 

607 _collect_arg(arg) 

608 if args.vararg is not None: 

609 _collect_arg(args.vararg) 

610 for arg in args.kwonlyargs: 

611 _collect_arg(arg) 

612 if args.kwarg is not None: 

613 _collect_arg(args.kwarg) 

614 

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

616 # leak to the module-level scope. 

617 self._push_scope(frozenset()) 

618 try: 

619 seen: set[str] = set() 

620 for name, owning_node in param_names: 

621 if name in seen: 

622 self._reject( 

623 "duplicate_parameter", 

624 owning_node, 

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

626 ) 

627 seen.add(name) 

628 self._bind_local(name, owning_node) 

629 for stmt in body: 

630 self.visit(stmt) 

631 finally: 

632 self._pop_scope() 

633 

634 # ---- expressions --------------------------------------------------- 

635 

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

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

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

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

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

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

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

643 # call or attribute allowlist. 

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

645 self._reject( 

646 "dunder_string", 

647 node, 

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

649 ) 

650 

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

652 if self._is_dunder(node.id): 

653 self._reject( 

654 "dunder_name", 

655 node, 

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

657 ) 

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

659 self._reject( 

660 "name_not_allowed", 

661 node, 

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

663 ) 

664 

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

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

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

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

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

670 self.visit(node.value) 

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

672 self._reject( 

673 "invalid_target", 

674 node.target, 

675 "walrus target must be a plain identifier", 

676 ) 

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

678 

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

680 # Lambdas are scoped expressions: validate parameters + body 

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

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

683 # produces no binding in the enclosing scope. 

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

685 

686 # ---- containers ---------------------------------------------------- 

687 

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

689 for elt in node.elts: 

690 self.visit(elt) 

691 

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

693 for elt in node.elts: 

694 self.visit(elt) 

695 

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

697 for elt in node.elts: 

698 self.visit(elt) 

699 

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

701 for key in node.keys: 

702 if key is not None: 

703 self.visit(key) 

704 else: 

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

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

707 # reason as in the predicate sandbox. 

708 self._reject( 

709 "dict_unpacking", 

710 node, 

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

712 ) 

713 for value in node.values: 

714 self.visit(value) 

715 

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

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

718 # inner expression so the nested Name still hits the 

719 # allowlist check. 

720 self.visit(node.value) 

721 

722 # ---- operators ----------------------------------------------------- 

723 

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

725 if not isinstance(node.op, _ALLOWED_BIN_OPS): 

726 self._reject( 

727 "binop_not_allowed", 

728 node, 

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

730 ) 

731 self.visit(node.left) 

732 self.visit(node.right) 

733 

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

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

736 self._reject( 

737 "unaryop_not_allowed", 

738 node, 

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

740 ) 

741 self.visit(node.operand) 

742 

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

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

745 self._reject( 

746 "boolop_not_allowed", 

747 node, 

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

749 ) 

750 for value in node.values: 

751 self.visit(value) 

752 

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

754 for op in node.ops: 

755 if not isinstance(op, _ALLOWED_COMPARE_OPS): 

756 self._reject( 

757 "compareop_not_allowed", 

758 node, 

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

760 ) 

761 self.visit(node.left) 

762 for comparator in node.comparators: 

763 self.visit(comparator) 

764 

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

766 self.visit(node.test) 

767 self.visit(node.body) 

768 self.visit(node.orelse) 

769 

770 # ---- attribute and subscript -------------------------------------- 

771 

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

773 # The script surface allows attribute access on exactly one 

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

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

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

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

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

779 if self._is_dunder(node.attr): 

780 self._reject( 

781 "dunder_attribute", 

782 node, 

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

784 ) 

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

786 self._reject( 

787 "attribute_target_not_name", 

788 node, 

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

790 ) 

791 if node.value.id != _MISSION_NAMESPACE_NAME: 

792 self._reject( 

793 "attribute_target_not_allowed", 

794 node, 

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

796 ) 

797 if node.attr not in _MISSION_HELPER_ATTRIBUTES: 

798 self._reject( 

799 "attribute_not_allowed", 

800 node, 

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

802 ) 

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

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

805 self.visit(node.value) 

806 

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

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

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

810 # allowlist; slices may themselves contain Names and Calls 

811 # that go through the same validation path. 

812 self.visit(node.value) 

813 self.visit(node.slice) 

814 

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

816 if node.lower is not None: 

817 self.visit(node.lower) 

818 if node.upper is not None: 

819 self.visit(node.upper) 

820 if node.step is not None: 

821 self.visit(node.step) 

822 

823 # ---- calls --------------------------------------------------------- 

824 

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

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

827 # allowed: 

828 # 

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

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

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

832 # only attribute-call shape supported. 

833 # 

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

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

836 # mission helper list are all rejected outright. 

837 func = node.func 

838 if isinstance(func, ast.Name): 

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

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

841 # one of those names. The dunder filter in 

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

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

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

845 if func.id in _FORBIDDEN_CALL_TARGETS: 

846 self._reject( 

847 "forbidden_call_target", 

848 node, 

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

850 ) 

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

852 self.visit(func) 

853 elif isinstance(func, ast.Attribute): 

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

855 # attribute visit raises with a structured reason for 

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

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

858 self.visit(func) 

859 else: 

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

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

862 # attribute access. Reject without descending; the 

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

864 self._reject( 

865 "call_target_shape", 

866 node, 

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

868 ) 

869 for arg in node.args: 

870 self.visit(arg) 

871 for kw in node.keywords: 

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

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

874 # against the same name and call rules. 

875 self.visit(kw.value) 

876 

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

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

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

880 # channel, where a registered async callable surfaces inside 

881 # the script as a coroutine factory. Calling 

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

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

884 # consuming the value requires writing ``await 

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

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

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

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

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

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

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

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

893 # 

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

895 # session tool allowlist. 

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

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

898 # attribute is one of the two helper names that 

899 # :meth:`visit_Attribute` already accepts. 

900 # 

901 # Both forms route the wrapped Call back through 

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

903 # forbidden-call-target rules apply unchanged. 

904 # 

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

906 # 

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

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

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

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

911 # or simply unknown). 

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

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

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

915 # token stable as ``await_not_allowed``. 

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

917 # ``mission`` — same rationale. 

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

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

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

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

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

923 # 

924 # ``AsyncFunctionDef`` / ``AsyncFor`` / ``AsyncWith`` continue 

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

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

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

928 # shapes. 

929 inner = node.value 

930 if not isinstance(inner, ast.Call): 

931 self._reject( 

932 "await_not_allowed", 

933 node, 

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

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

936 ) 

937 func = inner.func 

938 if isinstance(func, ast.Name): 

939 if func.id not in self._tool_allowlist: 

940 self._reject( 

941 "await_not_allowed", 

942 node, 

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

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

945 ) 

946 elif isinstance(func, ast.Attribute): 

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

948 if not ( 

949 isinstance(func.value, ast.Name) 

950 and func.value.id == _MISSION_NAMESPACE_NAME 

951 and func.attr in _MISSION_HELPER_ATTRIBUTES 

952 ): 

953 self._reject( 

954 "await_not_allowed", 

955 node, 

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

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

958 ) 

959 else: 

960 self._reject( 

961 "await_not_allowed", 

962 node, 

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

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

965 ) 

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

967 # machinery so kwargs, positional args, and the 

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

969 # for the non-awaited form. 

970 self.visit(inner) 

971 

972 # ---- f-strings ----------------------------------------------------- 

973 

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

975 for value in node.values: 

976 self.visit(value) 

977 

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

979 self.visit(node.value) 

980 if node.format_spec is not None: 

981 self.visit(node.format_spec) 

982 

983 # ---- comprehensions ----------------------------------------------- 

984 

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

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

987 

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

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

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

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

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

993 """ 

994 accumulated: set[str] = set() 

995 for gen in generators: 

996 if gen.is_async: 

997 self._reject( 

998 "async_comprehension", 

999 gen.iter, 

1000 "async comprehensions are not allowed", 

1001 ) 

1002 self.visit(gen.iter) 

1003 target_names = self._collect_target_names(gen.target) 

1004 for name_node in target_names: 

1005 if self._is_dunder(name_node.id): 

1006 self._reject( 

1007 "dunder_comprehension_target", 

1008 name_node, 

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

1010 ) 

1011 if name_node.id in self._base_scope: 

1012 self._reject( 

1013 "shadows_protected_name", 

1014 name_node, 

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

1016 ) 

1017 accumulated.add(name_node.id) 

1018 self._push_scope(frozenset(accumulated)) 

1019 try: 

1020 for if_clause in gen.ifs: 

1021 self.visit(if_clause) 

1022 finally: 

1023 self._pop_scope() 

1024 return frozenset(accumulated) 

1025 

1026 def _visit_comprehension_like( 

1027 self, 

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

1029 ) -> None: 

1030 locals_ = self._validate_comprehensions(node.generators) 

1031 self._push_scope(locals_) 

1032 try: 

1033 self.visit(node.elt) 

1034 finally: 

1035 self._pop_scope() 

1036 

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

1038 self._visit_comprehension_like(node) 

1039 

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

1041 self._visit_comprehension_like(node) 

1042 

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

1044 self._visit_comprehension_like(node) 

1045 

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

1047 locals_ = self._validate_comprehensions(node.generators) 

1048 self._push_scope(locals_) 

1049 try: 

1050 self.visit(node.key) 

1051 self.visit(node.value) 

1052 finally: 

1053 self._pop_scope() 

1054 

1055 

1056# --------------------------------------------------------------------------- 

1057# Public API 

1058# --------------------------------------------------------------------------- 

1059 

1060 

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

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

1063 

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

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

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

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

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

1069 

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

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

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

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

1074 lookup. 

1075 """ 

1076 if not isinstance(script, str): 

1077 raise ScriptRejected( 

1078 "not_a_string", 

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

1080 ) 

1081 try: 

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

1083 except SyntaxError as exc: 

1084 rejection = ScriptRejected( 

1085 "syntax_error", 

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

1087 ) 

1088 rejection.lineno = exc.lineno 

1089 rejection.col_offset = exc.offset 

1090 raise rejection from exc 

1091 _ScriptValidator(allowlist).visit(parsed) 

1092 

1093 

1094# =========================================================================== 

1095# Runtime layer — MissionSandbox wrapper around MontySandboxProvider 

1096# =========================================================================== 

1097# 

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

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

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

1101# layered on top: 

1102# 

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

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

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

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

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

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

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

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

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

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

1113# merges into the resulting Observation. 

1114# 

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

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

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

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

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

1120# verdict with the partial observation attached. 

1121 

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

1123import os # noqa: E402 

1124import time # noqa: E402 

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

1126from datetime import UTC, datetime # noqa: E402 

1127from types import MappingProxyType # noqa: E402 

1128from typing import Any # noqa: E402 

1129 

1130from . import audit as _audit # noqa: E402 

1131 

1132# --------------------------------------------------------------------------- 

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

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

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

1136# --------------------------------------------------------------------------- 

1137 

1138 

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

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

1141 

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

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

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

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

1146 still get a working sandbox. 

1147 """ 

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

1149 if not raw: 

1150 return default 

1151 try: 

1152 return int(raw) 

1153 except ValueError: 

1154 return default 

1155 

1156 

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

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

1159 

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

1161 float so fractional seconds remain expressible. 

1162 """ 

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

1164 if not raw: 

1165 return default 

1166 try: 

1167 return float(raw) 

1168 except ValueError: 

1169 return default 

1170 

1171 

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

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

1174# MissionSandbox. The defaults match the existing precedent in 

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

1176# Code Mode discovery transform's sandbox. 

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

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

1179 

1180 

1181# --------------------------------------------------------------------------- 

1182# Lazy import of the runtime dependencies 

1183# --------------------------------------------------------------------------- 

1184# 

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

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

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

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

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

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

1191# same process pay the import cost exactly once. 

1192 

1193_MONTY_PROVIDER_CLASS: Any = None 

1194_MONTY_ERROR_CLASS: Any = None 

1195 

1196 

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

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

1199 

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

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

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

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

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

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

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

1207 an opaque ``Exception``. 

1208 """ 

1209 global _MONTY_PROVIDER_CLASS, _MONTY_ERROR_CLASS 

1210 if _MONTY_PROVIDER_CLASS is None: 

1211 from fastmcp.experimental.transforms.code_mode import MontySandboxProvider 

1212 from pydantic_monty import MontyError 

1213 

1214 _MONTY_PROVIDER_CLASS = MontySandboxProvider 

1215 _MONTY_ERROR_CLASS = MontyError 

1216 return _MONTY_PROVIDER_CLASS, _MONTY_ERROR_CLASS 

1217 

1218 

1219# --------------------------------------------------------------------------- 

1220# Termination signal 

1221# --------------------------------------------------------------------------- 

1222 

1223 

1224class SandboxTerminated(Exception): 

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

1226 

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

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

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

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

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

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

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

1234 cap fired before the script returned. 

1235 

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

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

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

1239 the original Monty exception object. 

1240 """ 

1241 

1242 def __init__( 

1243 self, 

1244 cause: str, 

1245 *, 

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

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

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

1249 ) -> None: 

1250 self.cause: str = cause 

1251 # Defensive copies: callers occasionally inspect these lists 

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

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

1254 # closure corrupt the audit record. 

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

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

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

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

1259 # this onto the exception lets the engine's 

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

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

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

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

1264 # observe / event lists above. 

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

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

1267 

1268 

1269# --------------------------------------------------------------------------- 

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

1271# --------------------------------------------------------------------------- 

1272# 

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

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

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

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

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

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

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

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

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

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

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

1284# 

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

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

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

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

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

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

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

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

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

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

1295# nodes is the rewrite below. 

1296 

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

1298 "observe": "_mission_observe", 

1299 "event": "_mission_event", 

1300} 

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

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

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

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

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

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

1307assert set(_MISSION_HELPER_RUNTIME_NAMES) == set(_MISSION_HELPER_ATTRIBUTES) 

1308 

1309 

1310class _MissionAttributeCallRewriter(ast.NodeTransformer): 

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

1312 

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

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

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

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

1317 AST validator already vetted them, and the rewrite preserves 

1318 source positions so any subsequent error in those subtrees still 

1319 points at the operator's original column. 

1320 

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

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

1323 only ever encounters the two helper attributes; defensive 

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

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

1326 """ 

1327 

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

1329 # Recurse into args / kwargs first so a nested 

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

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

1332 # walks children and updates them in place. 

1333 self.generic_visit(node) 

1334 func = node.func 

1335 if ( 

1336 isinstance(func, ast.Attribute) 

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

1338 and func.value.id == _MISSION_NAMESPACE_NAME 

1339 and func.attr in _MISSION_HELPER_RUNTIME_NAMES 

1340 ): 

1341 replacement = ast.Name( 

1342 id=_MISSION_HELPER_RUNTIME_NAMES[func.attr], 

1343 ctx=ast.Load(), 

1344 ) 

1345 ast.copy_location(replacement, func) 

1346 node.func = replacement 

1347 return node 

1348 

1349 

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

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

1352 

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

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

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

1356 handing to ``MontySandboxProvider.run``. 

1357 """ 

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

1359 rewritten = _MissionAttributeCallRewriter().visit(tree) 

1360 ast.fix_missing_locations(rewritten) 

1361 return ast.unparse(rewritten) 

1362 

1363 

1364# --------------------------------------------------------------------------- 

1365# Tool callable wrapper 

1366# --------------------------------------------------------------------------- 

1367 

1368 

1369def _make_tool_wrapper( 

1370 tool_name: str, 

1371 ctx: Any | None, 

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

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

1374 session_id: str, 

1375 iteration_index: int, 

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

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

1378 

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

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

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

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

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

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

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

1386 machinery; that error surfaces through Monty as a 

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

1388 :meth:`MissionSandbox.run`. 

1389 

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

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

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

1393 as a Python exception the script can catch with 

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

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

1396 every invocation. 

1397 

1398 On both success and failure the wrapper also emits a 

1399 ``mission_script_call_event`` audit row tagged 

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

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

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

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

1404 lets consumers distinguish in-script invocations from direct 

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

1406 timestamps. 

1407 """ 

1408 

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

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

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

1412 # the dispatcher mutates the dict downstream. 

1413 args = dict(kwargs) 

1414 started = time.monotonic() 

1415 try: 

1416 result = await tool_dispatcher(tool_name, dict(args), ctx) 

1417 except Exception as exc: 

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

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

1420 script_call_log.append( 

1421 { 

1422 "tool_name": tool_name, 

1423 "args": args, 

1424 "status": "failed", 

1425 "result_summary": None, 

1426 "duration_ms": duration_ms, 

1427 # Truncated to 200 chars to match the audit 

1428 # module's existing convention for error_message 

1429 # fields elsewhere in the engine. 

1430 "error_message": error_message, 

1431 } 

1432 ) 

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

1434 # event is recorded even when the script catches the 

1435 # exception and continues executing. 

1436 _audit.emit_script_call_event( 

1437 session_id, 

1438 iteration_index, 

1439 tool_name, 

1440 "failed", 

1441 duration_ms, 

1442 error_message=error_message, 

1443 ) 

1444 raise 

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

1446 record: dict[str, Any] = { 

1447 "tool_name": tool_name, 

1448 "args": args, 

1449 "status": "ok", 

1450 "result_summary": result, 

1451 "duration_ms": duration_ms, 

1452 } 

1453 script_call_log.append(record) 

1454 _audit.emit_script_call_event( 

1455 session_id, 

1456 iteration_index, 

1457 tool_name, 

1458 "ok", 

1459 duration_ms, 

1460 ) 

1461 return result 

1462 

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

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

1465 # wrong inside the sandboxed script. The script_call_log remains 

1466 # the canonical record of what fired. 

1467 wrapper.__name__ = tool_name 

1468 return wrapper 

1469 

1470 

1471# --------------------------------------------------------------------------- 

1472# Observation assembly 

1473# --------------------------------------------------------------------------- 

1474 

1475 

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

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

1478 

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

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

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

1482 predicate evaluator and the ``tool_call_succeeded`` evaluator 

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

1484 

1485 Strategy: 

1486 

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

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

1489 caller-supplied marker visible while ensuring evaluators always 

1490 find them. 

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

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

1493 that holds the original payload so predicates can still walk 

1494 into it. 

1495 """ 

1496 result = call.get("result_summary") 

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

1498 tool_name = call.get("tool_name") 

1499 if isinstance(result, dict): 

1500 annotated = dict(result) 

1501 annotated.setdefault("_status", status) 

1502 annotated.setdefault("tool_name", tool_name) 

1503 return annotated 

1504 return { 

1505 "_status": status, 

1506 "tool_name": tool_name, 

1507 "result": result, 

1508 } 

1509 

1510 

1511def _build_script_observation( 

1512 *, 

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

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

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

1516 phase_started_at: str, 

1517 phase_ended_at: str, 

1518) -> dict[str, Any]: 

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

1520 

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

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

1523 Decide_Phase consumer cannot tell, from the Observation shape 

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

1525 Strategy: 

1526 

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

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

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

1530 successful tool result, exactly like the engine does. 

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

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

1533 walks one list. 

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

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

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

1537 

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

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

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

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

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

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

1544 stay distinct. 

1545 """ 

1546 tool_results: list[Any] = [] 

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

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

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

1550 

1551 for call in script_call_log: 

1552 tool_results.append(_annotate_call_result(call)) 

1553 if call.get("status") == "ok": 

1554 result = call.get("result_summary") 

1555 if isinstance(result, dict): 

1556 result_metrics = result.get("metrics") 

1557 if isinstance(result_metrics, dict): 

1558 metrics.update(result_metrics) 

1559 result_events = result.get("events") 

1560 if isinstance(result_events, list): 

1561 for event in result_events: 

1562 if isinstance(event, dict): 

1563 events.append(event) 

1564 else: 

1565 errors.append( 

1566 { 

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

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

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

1570 } 

1571 ) 

1572 

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

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

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

1576 # Observation. 

1577 for ev in event_log: 

1578 events.append(dict(ev)) 

1579 

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

1581 # metrics so they remain addressable without colliding with 

1582 # tool-derived metric names. 

1583 if observe_log: 

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

1585 for entry in observe_log: 

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

1587 metrics["observations"] = observations_bucket 

1588 

1589 observation: dict[str, Any] = { 

1590 "tool_results": tool_results, 

1591 "metrics": metrics, 

1592 "events": events, 

1593 "phase_started_at": phase_started_at, 

1594 "phase_ended_at": phase_ended_at, 

1595 } 

1596 if errors: 

1597 observation["errors"] = errors 

1598 return observation 

1599 

1600 

1601# --------------------------------------------------------------------------- 

1602# MissionSandbox 

1603# --------------------------------------------------------------------------- 

1604 

1605 

1606class MissionSandbox: 

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

1608 

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

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

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

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

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

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

1615 drives a single script execution end to end: 

1616 

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

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

1619 Execute_Phase with reason ``script_rejected``. 

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

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

1622 dispatcher so the wrapper preserves the existing 

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

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

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

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

1627 :class:`SandboxTerminated` carrying whatever the script 

1628 collected before being killed. 

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

1630 into an Observation dict whose shape exactly matches the 

1631 engine's tool-calls path. 

1632 

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

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

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

1636 own MissionSandbox so a stale frozen namespace cannot leak across 

1637 iterations. 

1638 """ 

1639 

1640 def __init__( 

1641 self, 

1642 allowlist: list[str], 

1643 session: Any, 

1644 ) -> None: 

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

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

1647 # reference would let later mutations slip past the AST 

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

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

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

1651 

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

1653 # snapshot. Each iteration summary carries only the four 

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

1655 # full IterationRecord shapes would be both heavy and 

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

1657 # support. 

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

1659 for it in session.get("iterations") or []: 

1660 iteration_summaries.append( 

1661 { 

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

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

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

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

1666 } 

1667 ) 

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

1669 # walks them via subscripting cannot mutate the session 

1670 # record even if Python's MappingProxyType were ever 

1671 # bypassed by a future change. 

1672 ns: dict[str, Any] = { 

1673 "session_id": session["session_id"], 

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

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

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

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

1678 "iterations": iteration_summaries, 

1679 } 

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

1681 

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

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

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

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

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

1687 # state above. 

1688 provider_cls, _ = _import_provider() 

1689 self._provider = provider_cls( 

1690 limits={ 

1691 "max_duration_secs": _DURATION_LIMIT_SECS, 

1692 "max_memory": _MEMORY_LIMIT_BYTES, 

1693 } 

1694 ) 

1695 

1696 # ---- read-only accessors ------------------------------------------ 

1697 

1698 @property 

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

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

1701 return self._frozen_mission_ns 

1702 

1703 @property 

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

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

1706 return list(self._allowlist) 

1707 

1708 # ---- public surface ----------------------------------------------- 

1709 

1710 async def run( 

1711 self, 

1712 script: str, 

1713 ctx: Any | None, 

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

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

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

1717 

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

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

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

1721 site) and the call log is a list of 

1722 :class:`ToolCallRecord`-shaped dicts. 

1723 

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

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

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

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

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

1729 ``terminate`` verdict for the iteration. 

1730 

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

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

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

1734 below. 

1735 """ 

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

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

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

1739 # rejection here means the runtime path never sees a 

1740 # disallowed source. 

1741 validate_script_ast(script, self._allowlist) 

1742 

1743 _, monty_error_cls = _import_provider() 

1744 

1745 # Closure-captured collectors. Populated synchronously by the 

1746 # host-side helper closures registered as 

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

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

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

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

1751 # in the final record. 

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

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

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

1755 

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

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

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

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

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

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

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

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

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

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

1766 # below receive the appends. 

1767 # 

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

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

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

1771 # positionally plus arbitrary keyword arguments. The AST 

1772 # rewrite below replaces the attribute callee with a bare 

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

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

1775 # operator would write at the script surface. 

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

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

1778 

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

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

1781 event_row.update(kwargs) 

1782 event_log.append(event_row) 

1783 

1784 # The frozen mission namespace remains pinned on this 

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

1786 # widening of the script surface can expose it without 

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

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

1789 # never accepts attribute access on anything other than 

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

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

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

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

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

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

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

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

1798 # observes. 

1799 

1800 # Build the external_functions mapping. Each tool name maps 

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

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

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

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

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

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

1807 # snapshot built at construction time so the wrapper records 

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

1809 # the iteration's audit rows carry. 

1810 session_id = self._frozen_mission_ns["session_id"] 

1811 iteration_index = self._frozen_mission_ns["iteration_index"] 

1812 for tool_name in self._allowlist: 

1813 external_functions[tool_name] = _make_tool_wrapper( 

1814 tool_name, 

1815 ctx, 

1816 tool_dispatcher, 

1817 script_call_log, 

1818 session_id, 

1819 iteration_index, 

1820 ) 

1821 

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

1823 # wrappers under reserved underscore-prefixed names. Operator 

1824 # scripts cannot collide with these: the AST validator 

1825 # rejects ``_mission_observe`` and ``_mission_event`` as 

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

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

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

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

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

1831 # ever produces those Name nodes. 

1832 external_functions["_mission_observe"] = _mission_observe 

1833 external_functions["_mission_event"] = _mission_event 

1834 

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

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

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

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

1839 # ``class`` / nested-attribute shims that would otherwise 

1840 # let us preserve the surface attribute call, so the 

1841 # rewrite happens on the AST itself before the source ever 

1842 # reaches the underlying VM. Operator code keeps its 

1843 # author-time surface (``await mission.observe(key, value)``); 

1844 # only the run-time surface differs. 

1845 final_source = _rewrite_mission_helpers(script) 

1846 

1847 phase_started_at = datetime.now(UTC).isoformat() 

1848 

1849 try: 

1850 await self._provider.run( 

1851 code=final_source, 

1852 inputs={}, 

1853 external_functions=external_functions, 

1854 ) 

1855 except monty_error_cls as exc: 

1856 # ``MontyError`` is the base of the limit / runtime / 

1857 # typing / syntax error family. Catching the base class 

1858 # rather than the leaves means a future Monty release 

1859 # adding a new error type still routes through 

1860 # ``SandboxTerminated`` rather than escaping as an opaque 

1861 # ``Exception``. 

1862 raise SandboxTerminated( 

1863 type(exc).__name__, 

1864 partial_observations=list(observe_log), 

1865 partial_events=list(event_log), 

1866 partial_script_call_log=list(script_call_log), 

1867 ) from exc 

1868 

1869 phase_ended_at = datetime.now(UTC).isoformat() 

1870 

1871 # The script's return value is intentionally ignored: the 

1872 # contract documented for the script surface is "use 

1873 # ``mission.observe(...)`` / ``mission.event(...)`` to report 

1874 # data". A script that returned a dict would conflict with 

1875 # the helper-driven observation list, and the engine's 

1876 # observe-phase already accepts a pre-built Observation 

1877 # without consulting any return value. 

1878 observation = _build_script_observation( 

1879 script_call_log=script_call_log, 

1880 observe_log=observe_log, 

1881 event_log=event_log, 

1882 phase_started_at=phase_started_at, 

1883 phase_ended_at=phase_ended_at, 

1884 ) 

1885 return observation, list(script_call_log) 

1886 

1887 

1888# --------------------------------------------------------------------------- 

1889# Default factory 

1890# --------------------------------------------------------------------------- 

1891 

1892 

1893def make_default_sandbox_runner( 

1894 allowlist: list[str], 

1895 session: Any, 

1896) -> Callable[ 

1897 [str, Any, Callable[[str, dict[str, Any], Any], Awaitable[Any]]], 

1898 Awaitable[tuple[dict[str, Any], list[dict[str, Any]]]], 

1899]: 

1900 """Build the default ``sandbox_runner`` callable for the engine. 

1901 

1902 The :class:`MissionEngine` takes a callable matching the 

1903 ``SandboxRunner`` protocol (``(script, ctx, tool_dispatcher) -> 

1904 (observation_dict, script_call_log)``); this helper wraps a fresh 

1905 :class:`MissionSandbox` for a given session and returns the bound 

1906 :meth:`MissionSandbox.run` method so the engine can drive the 

1907 sandbox without depending on the sandbox class itself. 

1908 

1909 One sandbox per session: the constructor freezes a snapshot of the 

1910 session's directive, criteria, budget, and prior-iteration 

1911 summaries into the ``mission`` namespace, so reusing a runner 

1912 across sessions would leak stale state. The engine's normal 

1913 construction path therefore calls this factory once per 

1914 ``mission_start`` and pins the returned callable on the engine 

1915 instance for the session's lifetime. 

1916 """ 

1917 sandbox = MissionSandbox( 

1918 allowlist=allowlist, 

1919 session=session, 

1920 ) 

1921 return sandbox.run 

1922 

1923 

1924# --------------------------------------------------------------------------- 

1925# Public surface 

1926# --------------------------------------------------------------------------- 

1927 

1928 

1929__all__ = [ 

1930 "MissionSandbox", 

1931 "ScriptRejected", 

1932 "SandboxTerminated", 

1933 "make_default_sandbox_runner", 

1934 "validate_script_ast", 

1935]