Verb Sandbox Security Reference
For background on why the sandbox exists and how the execution environment is structured, see Why the Verb Sandbox Exists. For the patterns verb authors actually need to know to write safe code, the short list at the end of this page is the practical summary.
Restricted builtins
- moo.settings.base.ALLOWED_BUILTINS
Built-in immutable sequence.
If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable’s items.
If the argument is a tuple, the return value is the same object.
Everything else — including type, dir, eval, exec, compile,
open, vars, globals, locals, __import__ — is absent.
type is the most important exclusion.
type(obj).__mro__[-1].__subclasses__() is the canonical Python
sandbox escape: it walks up the inheritance hierarchy to object,
then lists all subclasses currently loaded in the interpreter, which
includes Django model classes. From there a caller can reach
Object.objects.all() directly.
dir is unused in all verb code and returns dunder names that are
useful for reconnaissance. eval and exec would allow dynamic code
generation that bypasses compile-time restrictions.
getattr and hasattr are included but wrapped: the sandbox replaces
them with versions that raise AttributeError for any name beginning
with an underscore. This prevents getattr(obj, '__class__') from
bypassing the _getattr_ rewrite that RestrictedPython applies to the
. syntax.
The Python 2 __metaclass__=type entry that historical convention
placed in the globals dict has also been removed; it exposed type
directly regardless of ALLOWED_BUILTINS.
Module imports
- moo.settings.base.ALLOWED_MODULES
Built-in immutable sequence.
If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable’s items.
If the argument is a tuple, the return value is the same object.
restricted_import() in moo/core/code.py enforces this list. Any
import not in ALLOWED_MODULES raises ImportError. moo.core is
absent on purpose — verb code cannot reach Django ORM model classes
(Object.objects, User.objects, etc.) or internal framework
machinery.
Wizard-only modules
- moo.settings.base.WIZARD_ALLOWED_MODULES
Built-in immutable sequence.
If no argument is given, the constructor returns an empty tuple. If iterable is specified the tuple is initialized from iterable’s items.
If the argument is a tuple, the return value is the same object.
restricted_import() checks is_wizard() on the current caller
before allowing these. Non-wizards attempting to import any of them
get the same ImportError as for unlisted modules.
BLOCKED_IMPORTS
- moo.settings.base.BLOCKED_IMPORTS
moo.sdksubmodule names that must not be importable by verb code. Thecontextsubmodule is intentionally absent — its name is shadowed in__init__.pyby the exported_Context()singleton, sofrom moo.sdk import contextalready returns the singleton, not the module. Other dangerous names (ContextManager,contextmanager,log) are protected by underscore aliases inside the sdk package rather than appearing here.
BLOCKED_IMPORTS is checked after a module is loaded; if the
requested name appears in the block list for that module, the import
is refused. The current entries are moo.sdk submodule names —
without these, verb code could do from moo.sdk import tasks and
reach the unwrapped Celery task layer.
Other dangerous names exported from moo.sdk (ContextManager,
contextmanager, log) are blocked by being aliased to underscore
names inside moo/sdk/__init__.py rather than via this table —
underscore-prefixed names are rejected by the attribute guard
regardless.
NoSuchObjectError, NoSuchVerbError, NoSuchPropertyError, and the
other public exceptions are exported normally and importable with
from moo.sdk import NoSuchObjectError.
Removed modules
string was removed from ALLOWED_MODULES.
string.Formatter.get_field calls CPython’s real getattr internally
— not the sandbox’s guarded version — so
string.Formatter().get_field("0.__class__", [lookup(1)], {}) exposed
__class__ and from there the full ORM.
Attribute and item access guards
get_restricted_environment() provides guard functions that
RestrictedPython calls for every attribute and item operation. There
are two parallel attribute guards:
get_protected_attribute(obj, name)is installed as_getattr_. RestrictedPython rewrites every dotted read (obj.attr) to call this function at compile time.safe_getattr(obj, name, *args)replaces the builtingetattr. Both functions apply the same rules; the duplication is necessary because the builtingetattr(obj, name)call form is not rewritten by the RestrictedPython compiler.
Underscore attribute blocking
Both guards raise AttributeError for any name starting with _.
This covers the dotted syntax (obj.__class__) which RestrictedPython
rewrites at compile time, and the builtin call form
(getattr(obj, '__class__')) which the wrapped getattr / hasattr
intercepts.
safe_hasattr(obj, name) returns False for underscore names rather
than raising, matching the documented behavior of the builtin
hasattr.
str.format and str.format_map
Both guards raise AttributeError when name in ("format", "format_map") and isinstance(obj, str). Python’s C-level string
formatting engine resolves attribute chains using the real getattr
internally, so '{0.__class__}'.format(obj) would expose __class__
on any object — including Django ORM instances from lookup().
Constructing the format string at runtime
(('{0.' + '__class__' + '}').format(obj)) made static scanning
useless. Blocking format and format_map on string instances
closes this.
As a consequence, verb code must not call str.format(). Use
f-strings or str.replace() instead.
QuerySet and BaseManager restrictions
- moo.core.code._QUERYSET_ALLOWED
frozenset() -> empty frozenset object frozenset(iterable) -> frozenset object
Build an immutable unordered collection of unique elements.
Both attribute guards check isinstance(obj, (QuerySet, BaseManager))
and allow only the names in _QUERYSET_ALLOWED. Every other method
or attribute on a QuerySet or manager instance raises
AttributeError. This covers:
Bulk mutation methods:
update(),delete(),create()— which issue SQL directly, bypassing the modelsave()/delete()permission hooks.values()andvalues_list()— which return plain dicts whose"value"keys are notPropertyinstances, so theProperty.valueread guard would not fire.add()andremove()on ManyToMany managers — which issue SQL directly, bypassing ACL checks on the owning object.model— which exposes the raw Django model class, opening the path toObject.objects.all().All async variants (
adelete,aupdate,acreate, etc.) and any future Django additions are blocked by default unless explicitly added.
select_related() and prefetch_related() are allowed because they
are actively used by verb code and return a new QuerySet of the same
type — the instances they produce still go through the attribute
guards when accessed.
acl and value attribute guards
Both guards include two additional permission checks:
Accessing
aclon anyAccessibleMixininstance callsobj.can_caller("grant", obj). Theaclattribute is a RelatedManager — without this check, verb code could enumerate ACL entries and read permission rules they should not know about.Accessing
valueon aPropertyinstance callsobj.origin.can_caller("read", obj). This ensures that obtaining aPropertyobject viaobj.properties.filter(...).first()does not bypass the permission-checkedobj.get_property()path.
Module traversal blocking
Both guards check attribute accesses on ModuleType instances to
prevent walking across module boundaries. When a dotted access on a
module returns another module, that nested module must appear in
ALLOWED_MODULES or WIZARD_ALLOWED_MODULES; otherwise
AttributeError is raised. The BLOCKED_IMPORTS table is also
checked — names blocked from import are equally blocked from
attribute access on the module object.
Item access guards
_write_.__setitem__ raises KeyError for any string key starting
with _. The matching read-side guard guarded_getitem(obj, key)
raises KeyError for the same. This prevents underscore keys from
being written to or read from dicts in restricted code.
The _write_ class
_write_(obj) wraps an object for attribute writes. Its
__setattr__ calls set_protected_attribute(obj, name, value),
which raises AttributeError for underscore-prefixed names and, for
any AccessibleMixin instance, calls obj.can_caller("write", obj)
before setting the attribute. Its __setitem__ mirrors the key
guard. This means both obj.__class__ = x and obj['__class__'] = x
are blocked, and any attribute write to an object the caller does
not own raises an access error.
safe_builtins isolation
get_restricted_environment() builds
restricted_builtins = dict(safe_builtins) as a local copy on each
call. The original safe_builtins from RestrictedPython is a
module-level singleton; mutating it in place would create a race
window in concurrent workers where the real getattr could be
momentarily visible.
Context isolation
The ContextManager in moo/core/code.py stores per-execution state
(caller, player, writer, parser, task_id) in Python contextvars,
which are inherited by child tasks but isolated across concurrent
executions in the same worker. Verb code accesses this via the
context object exported from moo.sdk.
caller_stack copy
ContextManager.get("caller_stack") returns list(stack) — a copy
of the internal list, not the list itself. Returning the live list
would allow verb code to call
context.caller_stack.append({"previous_caller": wizard_obj}),
poisoning the stack. When a wizard verb’s set_task_perms finished
and called pop_caller(), it would restore _active_caller to the
injected wizard object.
_Context as a data descriptor
The _Context class backing the context object uses data
descriptors (both __get__ and __set__ defined). Non-data
descriptors (only __get__) lose priority to instance attributes in
Python’s MRO. With a non-data descriptor, _write_ could call
setattr(context, "caller", wizard_obj) to shadow the
contextvar-backed descriptor with an instance attribute. Since
context is a module-level singleton shared within a Celery worker
process, this would poison context.caller.is_wizard() for all
subsequent tasks in that worker. Making it a data descriptor — where
__set__ raises AttributeError — closes this.
_Context.__setattr__ also raises AttributeError as
defense-in-depth.
invoke() guards
invoke() in moo/core/__init__.py has two security checks:
periodic=Trueorcron=...requires the caller to be a wizard. Non-wizards could otherwise create unlimitedIntervalScheduleandPeriodicTaskdatabase rows, flooding the Celery beat schedule.All invocations check
exec_obj.can_caller("execute", verb).Object.invoke_verb()enforces this too, butinvoke()accepted rawVerbobjects and previously bypassed it, allowing a caller with onlyreadaccess to enqueue any verb.
set_task_perms
set_task_perms() raises UserError for non-wizards. This was
enforced before the audit; a regression test guards against silent
removal.
Model-level permission checks
Even with all import and attribute guards in place, verb code can
obtain Django model instances through indirect means. For example,
obj.properties is a RelatedManager — its name has no underscore,
so _getattr_ allows access. Via
obj.properties.filter(name='x').first(), verb code can obtain a
Property instance directly, bypassing the permission-checked
obj.get_property() path.
To close this, the model save(), delete(), and __call__()
methods enforce permissions as the last line of defense:
Verb.save()callsself.origin.can_caller("write", self)beforesuper().save()for both creates and updates.Property.save()callsself.origin.can_caller("write", self)beforesuper().save()for updates.Object.delete()callsself.can_caller("write", self)as its first action.Verb.__call__()callsself.origin.can_caller("execute", self)when an active session is present. Thepassthrough()builtin passes_bypass_execute_check=Trueto skip this redundant check when a parent verb has already been authorised.
These checks mean that even if verb code obtains a model instance through an unguarded path, persisting changes or executing verbs still requires the caller to hold the appropriate permission on the owning object.
For the full list of model-layer permission checks see Permissions Reference.
Known gap: dict.update() and underscore keys
dict.update({'__class__': x}) inserts underscore keys at the C
level, bypassing _write_.__setitem__. Those keys can then be
retrieved via dict.get(), .items(), or .values(), bypassing
guarded_getitem. Standalone exploitability is low — with
str.format/format_map blocked, there is no obvious way to turn
an underscore key in a plain dict into ORM access. The inconsistency
is documented in test_dict_update_bypasses_write_guard in
moo/core/tests/test_security_sandbox.py. Closing it fully would
require subclassing dict, which risks breaking legitimate verb
code that passes dicts to standard library functions.
Writing safe verb code
A few patterns to avoid in verb code, and what to use instead:
String formatting. str.format() and str.format_map() are
blocked. Use f-strings for dynamic content and str.replace() for
stored message templates with named slots.
Property access. has_property(name) followed by
get_property(name) is two queries. Use get_property inside a
try/except NoSuchPropertyError block:
from moo.sdk import NoSuchPropertyError
try:
desc = this.get_property("description")
except NoSuchPropertyError:
desc = "You see nothing special."
Attribute access. Dotted access via __getattr__ on an Object
costs two queries (verb miss + property lookup). Assign to a local
variable if the value is used more than once:
dest = this.get_property("dest")
# use dest, not this.dest, for the rest of the verb
Dunder attributes. Any attribute name beginning with _ will
raise AttributeError. Do not attempt to access __class__,
__dict__, __module__, or any other dunder on objects in verb
code.
Underscore dict keys. Setting or reading dict keys that begin
with _ will raise KeyError. Rarely needed in practice.
Security regression tests
The security tests live in moo/core/tests/test_security_*.py,
split by area:
test_security_builtins.py— restricted builtins, dunder access viagetattr/hasattr,str.format/format_map.test_security_imports.py— module imports,BLOCKED_IMPORTS, wizard-only modules,string.Formatterremoval, module traversal.test_security_sandbox.py— underscore attribute blocking, the_write_class, dict key guards,safe_builtinsisolation.test_security_context.py—caller_stackcopy,_Contextdata descriptor,set_task_permsnon-wizard rejection,invoke()guards.test_security_model_acl.py,test_security_model_object.py,test_security_model_property.py,test_security_model_verb.py,test_security_model_mail.py— model-layer permission checks per model.test_security_queryset.py— QuerySet/BaseManager mutation methods,values(), M2Madd(),modelattribute,Property.valueguard, ACL enumeration guard,select_related()safety.test_security_random.py—randommodule exposure boundaries.
Run the full set after any change to moo/core/code.py,
moo/settings/base.py, or any model save()/delete()/__call__()
method.