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
« 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.
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-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
108# ---------------------------------------------------------------------------
109# Allowlists
110# ---------------------------------------------------------------------------
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."""
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``.
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"""
166_MISSION_NAMESPACE_NAME: Final[str] = "mission"
167"""Top-level identifier reserved for the per-iteration helper namespace."""
169_MISSION_HELPER_ATTRIBUTES: Final[frozenset[str]] = frozenset({"observe", "event"})
170"""Only attributes the validator accepts on the ``mission`` namespace."""
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.
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"""
182_ALLOWED_DECORATORS: Final[frozenset[str]] = frozenset()
183"""Decorator names a function definition may carry.
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"""
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)
201_ALLOWED_UNARY_OPS: Final[tuple[type[ast.unaryop], ...]] = (
202 ast.UAdd,
203 ast.USub,
204 ast.Not,
205 ast.Invert,
206)
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)
221_ALLOWED_BOOL_OPS: Final[tuple[type[ast.boolop], ...]] = (ast.And, ast.Or)
224# ---------------------------------------------------------------------------
225# Exception
226# ---------------------------------------------------------------------------
229class ScriptRejected(Exception):
230 """Raised when a script source contains a disallowed construct.
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 """
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)
262# ---------------------------------------------------------------------------
263# Validator
264# ---------------------------------------------------------------------------
267class _ScriptValidator(ast.NodeVisitor):
268 """Walk a script AST and reject any construct outside the allowlist.
270 The validator tracks two things across the walk:
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 """
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)
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 )
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()]
321 # ---- helpers -------------------------------------------------------
323 def _current_locals(self) -> frozenset[str]:
324 return self._scopes[-1]
326 def _name_is_visible(self, name: str) -> bool:
327 return name in self._base_scope or name in self._current_locals()
329 @staticmethod
330 def _is_dunder(name: str) -> bool:
331 return name.startswith("__")
333 @staticmethod
334 def _reject(reason: str, node: ast.AST, message: str | None = None) -> NoReturn:
335 raise ScriptRejected(reason, failing_node=node, message=message)
337 def _push_scope(self, locals_: frozenset[str]) -> None:
338 self._scopes.append(self._current_locals() | locals_)
340 def _pop_scope(self) -> None:
341 self._scopes.pop()
343 def _bind_local(self, name: str, node: ast.AST) -> None:
344 """Add ``name`` to the current frame, rejecting protected shadows.
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}
370 def _collect_target_names(self, target: ast.AST) -> list[ast.Name]:
371 """Flatten an assignment / for / comprehension target.
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
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)
399 # ---- top-level entry ----------------------------------------------
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)
409 # ---- catch-all -----------------------------------------------------
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 )
426 # ---- statements ----------------------------------------------------
428 def visit_Expr(self, node: ast.Expr) -> None:
429 self.visit(node.value)
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
436 def visit_Break(self, node: ast.Break) -> None:
437 pass
439 def visit_Continue(self, node: ast.Continue) -> None:
440 pass
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)
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)
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)
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)
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)
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)
519 def visit_Return(self, node: ast.Return) -> None:
520 if node.value is not None:
521 self.visit(node.value)
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)
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)
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)
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)
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]] = []
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)
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)
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()
634 # ---- expressions ---------------------------------------------------
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 )
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 )
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)
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)
686 # ---- containers ----------------------------------------------------
688 def visit_List(self, node: ast.List) -> None:
689 for elt in node.elts:
690 self.visit(elt)
692 def visit_Tuple(self, node: ast.Tuple) -> None:
693 for elt in node.elts:
694 self.visit(elt)
696 def visit_Set(self, node: ast.Set) -> None:
697 for elt in node.elts:
698 self.visit(elt)
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)
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)
722 # ---- operators -----------------------------------------------------
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)
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)
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)
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)
765 def visit_IfExp(self, node: ast.IfExp) -> None:
766 self.visit(node.test)
767 self.visit(node.body)
768 self.visit(node.orelse)
770 # ---- attribute and subscript --------------------------------------
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)
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)
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)
823 # ---- calls ---------------------------------------------------------
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)
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)
972 # ---- f-strings -----------------------------------------------------
974 def visit_JoinedStr(self, node: ast.JoinedStr) -> None:
975 for value in node.values:
976 self.visit(value)
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)
983 # ---- comprehensions -----------------------------------------------
985 def _validate_comprehensions(self, generators: list[ast.comprehension]) -> frozenset[str]:
986 """Walk comprehension generators and return their target names.
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)
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()
1037 def visit_ListComp(self, node: ast.ListComp) -> None:
1038 self._visit_comprehension_like(node)
1040 def visit_SetComp(self, node: ast.SetComp) -> None:
1041 self._visit_comprehension_like(node)
1043 def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
1044 self._visit_comprehension_like(node)
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()
1056# ---------------------------------------------------------------------------
1057# Public API
1058# ---------------------------------------------------------------------------
1061def validate_script_ast(script: str, allowlist: list[str]) -> None:
1062 """Parse and validate a Mission script source string.
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.
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)
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.
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
1130from . import audit as _audit # noqa: E402
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# ---------------------------------------------------------------------------
1139def _int_env(name: str, default: int) -> int:
1140 """Parse an integer env var; fall back to default on missing/empty/non-numeric.
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
1157def _float_env(name: str, default: float) -> float:
1158 """Parse a float env var; fall back to default on missing/empty/non-numeric.
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
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)
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.
1193_MONTY_PROVIDER_CLASS: Any = None
1194_MONTY_ERROR_CLASS: Any = None
1197def _import_provider() -> tuple[Any, Any]:
1198 """Lazy-import ``MontySandboxProvider`` and ``MontyError`` and cache them.
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
1214 _MONTY_PROVIDER_CLASS = MontySandboxProvider
1215 _MONTY_ERROR_CLASS = MontyError
1216 return _MONTY_PROVIDER_CLASS, _MONTY_ERROR_CLASS
1219# ---------------------------------------------------------------------------
1220# Termination signal
1221# ---------------------------------------------------------------------------
1224class SandboxTerminated(Exception):
1225 """Raised when the Monty sandbox killed the script for exceeding a limit.
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.
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 """
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}")
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.
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)
1310class _MissionAttributeCallRewriter(ast.NodeTransformer):
1311 """Rewrite ``mission.observe(...)`` / ``mission.event(...)`` callees.
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.
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 """
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
1350def _rewrite_mission_helpers(script: str) -> str:
1351 """Re-parse ``script``, rewrite mission helper calls, and unparse.
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)
1364# ---------------------------------------------------------------------------
1365# Tool callable wrapper
1366# ---------------------------------------------------------------------------
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``.
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`.
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.
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 """
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
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
1471# ---------------------------------------------------------------------------
1472# Observation assembly
1473# ---------------------------------------------------------------------------
1476def _annotate_call_result(call: dict[str, Any]) -> Any:
1477 """Wrap a script-call ``result_summary`` with per-call markers.
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.
1485 Strategy:
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 }
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.
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:
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.
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]] = []
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 )
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))
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
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
1601# ---------------------------------------------------------------------------
1602# MissionSandbox
1603# ---------------------------------------------------------------------------
1606class MissionSandbox:
1607 """Run a validated Mission script under ``MontySandboxProvider`` limits.
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:
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.
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 """
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)
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)
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 )
1696 # ---- read-only accessors ------------------------------------------
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
1703 @property
1704 def allowlist(self) -> list[str]:
1705 """Defensive copy of the per-session tool allowlist."""
1706 return list(self._allowlist)
1708 # ---- public surface -----------------------------------------------
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.
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.
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.
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)
1743 _, monty_error_cls = _import_provider()
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]] = []
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})
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)
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.
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 )
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
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)
1847 phase_started_at = datetime.now(UTC).isoformat()
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
1869 phase_ended_at = datetime.now(UTC).isoformat()
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)
1888# ---------------------------------------------------------------------------
1889# Default factory
1890# ---------------------------------------------------------------------------
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.
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.
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
1924# ---------------------------------------------------------------------------
1925# Public surface
1926# ---------------------------------------------------------------------------
1929__all__ = [
1930 "MissionSandbox",
1931 "ScriptRejected",
1932 "SandboxTerminated",
1933 "make_default_sandbox_runner",
1934 "validate_script_ast",
1935]