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
« 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.
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`.
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.
23Two layers, same as the predicate sandbox:
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.
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``.
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.
50**Names visible to a script (the *base scope*):**
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.
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.
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.
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"""
92from __future__ import annotations
94import ast
95from collections.abc import Iterable
96from typing import Final, NoReturn
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
107# ---------------------------------------------------------------------------
108# Allowlists
109# ---------------------------------------------------------------------------
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."""
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``.
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"""
165_MISSION_NAMESPACE_NAME: Final[str] = "mission"
166"""Top-level identifier reserved for the per-iteration helper namespace."""
168_MISSION_HELPER_ATTRIBUTES: Final[frozenset[str]] = frozenset({"observe", "event"})
169"""Only attributes the validator accepts on the ``mission`` namespace."""
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.
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"""
181_ALLOWED_DECORATORS: Final[frozenset[str]] = frozenset()
182"""Decorator names a function definition may carry.
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"""
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)
200_ALLOWED_UNARY_OPS: Final[tuple[type[ast.unaryop], ...]] = (
201 ast.UAdd,
202 ast.USub,
203 ast.Not,
204 ast.Invert,
205)
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)
220_ALLOWED_BOOL_OPS: Final[tuple[type[ast.boolop], ...]] = (ast.And, ast.Or)
223# ---------------------------------------------------------------------------
224# Exception
225# ---------------------------------------------------------------------------
228class ScriptRejected(Exception):
229 """Raised when a script source contains a disallowed construct.
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 """
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)
261# ---------------------------------------------------------------------------
262# Validator
263# ---------------------------------------------------------------------------
266class _ScriptValidator(ast.NodeVisitor):
267 """Walk a script AST and reject any construct outside the allowlist.
269 The validator tracks two things across the walk:
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 """
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)
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 )
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()]
320 # ---- helpers -------------------------------------------------------
322 def _current_locals(self) -> frozenset[str]:
323 return self._scopes[-1]
325 def _name_is_visible(self, name: str) -> bool:
326 return name in self._base_scope or name in self._current_locals()
328 @staticmethod
329 def _is_dunder(name: str) -> bool:
330 return name.startswith("__")
332 @staticmethod
333 def _reject(reason: str, node: ast.AST, message: str | None = None) -> NoReturn:
334 raise ScriptRejected(reason, failing_node=node, message=message)
336 def _push_scope(self, locals_: frozenset[str]) -> None:
337 self._scopes.append(self._current_locals() | locals_)
339 def _pop_scope(self) -> None:
340 self._scopes.pop()
342 def _bind_local(self, name: str, node: ast.AST) -> None:
343 """Add ``name`` to the current frame, rejecting protected shadows.
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}
369 def _collect_target_names(self, target: ast.AST) -> list[ast.Name]:
370 """Flatten an assignment / for / comprehension target.
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
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)
398 # ---- top-level entry ----------------------------------------------
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)
408 # ---- catch-all -----------------------------------------------------
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 )
425 # ---- statements ----------------------------------------------------
427 def visit_Expr(self, node: ast.Expr) -> None:
428 self.visit(node.value)
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
435 def visit_Break(self, node: ast.Break) -> None:
436 pass
438 def visit_Continue(self, node: ast.Continue) -> None:
439 pass
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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]] = []
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)
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)
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()
633 # ---- expressions ---------------------------------------------------
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 )
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 )
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)
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)
685 # ---- containers ----------------------------------------------------
687 def visit_List(self, node: ast.List) -> None:
688 for elt in node.elts:
689 self.visit(elt)
691 def visit_Tuple(self, node: ast.Tuple) -> None:
692 for elt in node.elts:
693 self.visit(elt)
695 def visit_Set(self, node: ast.Set) -> None:
696 for elt in node.elts:
697 self.visit(elt)
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)
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)
721 # ---- operators -----------------------------------------------------
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)
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)
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)
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)
764 def visit_IfExp(self, node: ast.IfExp) -> None:
765 self.visit(node.test)
766 self.visit(node.body)
767 self.visit(node.orelse)
769 # ---- attribute and subscript --------------------------------------
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)
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)
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)
822 # ---- calls ---------------------------------------------------------
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)
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)
971 # ---- f-strings -----------------------------------------------------
973 def visit_JoinedStr(self, node: ast.JoinedStr) -> None:
974 for value in node.values:
975 self.visit(value)
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)
982 # ---- comprehensions -----------------------------------------------
984 def _validate_comprehensions(self, generators: list[ast.comprehension]) -> frozenset[str]:
985 """Walk comprehension generators and return their target names.
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)
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()
1036 def visit_ListComp(self, node: ast.ListComp) -> None:
1037 self._visit_comprehension_like(node)
1039 def visit_SetComp(self, node: ast.SetComp) -> None:
1040 self._visit_comprehension_like(node)
1042 def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
1043 self._visit_comprehension_like(node)
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()
1055# ---------------------------------------------------------------------------
1056# Public API
1057# ---------------------------------------------------------------------------
1060def validate_script_ast(script: str, allowlist: list[str]) -> None:
1061 """Parse and validate a Mission script source string.
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.
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)
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.
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
1129from . import audit as _audit # noqa: E402
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# ---------------------------------------------------------------------------
1138def _int_env(name: str, default: int) -> int:
1139 """Parse an integer env var; fall back to default on missing/empty/non-numeric.
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
1156def _float_env(name: str, default: float) -> float:
1157 """Parse a float env var; fall back to default on missing/empty/non-numeric.
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
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)
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.
1192_MONTY_PROVIDER_CLASS: Any = None
1193_MONTY_ERROR_CLASS: Any = None
1196def _import_provider() -> tuple[Any, Any]:
1197 """Lazy-import ``MontySandboxProvider`` and ``MontyError`` and cache them.
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
1213 _MONTY_PROVIDER_CLASS = MontySandboxProvider
1214 _MONTY_ERROR_CLASS = MontyError
1215 return _MONTY_PROVIDER_CLASS, _MONTY_ERROR_CLASS
1218# ---------------------------------------------------------------------------
1219# Termination signal
1220# ---------------------------------------------------------------------------
1223class SandboxTerminated(Exception):
1224 """Raised when the Monty sandbox killed the script for exceeding a limit.
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.
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 """
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}")
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.
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)
1309class _MissionAttributeCallRewriter(ast.NodeTransformer):
1310 """Rewrite ``mission.observe(...)`` / ``mission.event(...)`` callees.
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.
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 """
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
1349def _rewrite_mission_helpers(script: str) -> str:
1350 """Re-parse ``script``, rewrite mission helper calls, and unparse.
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)
1363# ---------------------------------------------------------------------------
1364# Tool callable wrapper
1365# ---------------------------------------------------------------------------
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``.
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`.
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.
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 """
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
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
1470# ---------------------------------------------------------------------------
1471# Observation assembly
1472# ---------------------------------------------------------------------------
1475def _annotate_call_result(call: dict[str, Any]) -> Any:
1476 """Wrap a script-call ``result_summary`` with per-call markers.
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.
1484 Strategy:
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 }
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.
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:
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.
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]] = []
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 )
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))
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
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
1600# ---------------------------------------------------------------------------
1601# MissionSandbox
1602# ---------------------------------------------------------------------------
1605class MissionSandbox:
1606 """Run a validated Mission script under ``MontySandboxProvider`` limits.
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:
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.
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 """
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)
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)
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 )
1695 # ---- read-only accessors ------------------------------------------
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
1702 @property
1703 def allowlist(self) -> list[str]:
1704 """Defensive copy of the per-session tool allowlist."""
1705 return list(self._allowlist)
1707 # ---- public surface -----------------------------------------------
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.
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.
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.
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)
1742 _, monty_error_cls = _import_provider()
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]] = []
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})
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)
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.
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 )
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
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)
1846 phase_started_at = datetime.now(UTC).isoformat()
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
1868 phase_ended_at = datetime.now(UTC).isoformat()
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)
1887# ---------------------------------------------------------------------------
1888# Default factory
1889# ---------------------------------------------------------------------------
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.
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.
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
1923# ---------------------------------------------------------------------------
1924# Public surface
1925# ---------------------------------------------------------------------------
1928__all__ = [
1929 "MissionSandbox",
1930 "ScriptRejected",
1931 "SandboxTerminated",
1932 "make_default_sandbox_runner",
1933 "validate_script_ast",
1934]