diff --git a/contrib/evlang/README b/contrib/evlang/README new file mode 100644 index 0000000000..edaa7e0d3b --- /dev/null +++ b/contrib/evlang/README @@ -0,0 +1,109 @@ + +EVLANG + +EXPERIMENTAL IMPLEMENTATION + +Evennia contribution - Griatch 2012 + +"Evlang" is a heavily restricted version of Python intended to be used +by regular players to code simple functionality on supporting objects. +It's referred to as "evlang" or "evlang scripts" in order to +differentiate from Evennia's normal (and unrelated) "Scripts". + +WARNING: + Restricted python execution is a tricky art, and this module -is- + partly based on blacklisting techniques, which might be vulnerable to + new venues of attack opening up in the future (or existing ones we've + missed). Whereas I/we know of no obvious exploits to this, it is no + guarantee. If you are paranoid about security, consider also using + secondary defences on the OS level such as a jail and highly + restricted execution abilities for the twisted process. So in short, + this should work fine, but use it at your own risk. You have been + warned. + +An Evennia server with Evlang will, once set up, minimally consist of +the following components: + + - The evlang parser (bottom of evlang.py). This combines + regular removal of dangerous modules/builtins with AST-traversal. + it implements a limited_exec() function. + - The Evlang handler (top of evlang.py). This handler is the Evennia + entry point. It should be added to objects that should support + evlang-scripting. + - A custom object typeclass. This must set up the Evlang handler + and store a few critical Attributes on itself for book-keeping. + The object will probably also overload some of its hooks to + call the correct evlang script at the proper time + - Command(s) for adding code to supporting objects + - Optional expanded "safe" methods/objects to include in the + execution environment. These are defined in settings (see + header of evlang.py for more info). + +You can set this up easily to try things out by using the included +examples: + +Quick Example Install +--------------------- + +This is a quick test-setup using the example objects and commands. + +1) If you haven't already, make sure you are able to overload the + default cmdset: Copy game/gamesrc/commands/examples/cmdset.py up one level, + then change settings.CMDSET_DEFAULT to point to DefaultCmdSet in your newly copied module. + Restart the server and check so the default commands still work. +2) Import and add + contrib.evlang.command.CmdCode + and + contrib.evlang.examples.CmdCraftScriptable + to your default command set. Reload server. + +That's it, really. You should now have two new commands available, +@craftscriptable and @code. The first one is a simple "crafting-like" +command that will create an object of type +contrib.evlang.examples.CraftedScriptableObject while setting it up +with some basic scripting slots. + +Try it now: + + @craftscriptable crate + +You create a simple "crate" object in your current location. You can +use @code to see which "code types" it will accept. + + @code crate + +You should see a list with "drop", "get" and "look", each without +anything assigned to them. If you look at how CraftedScriptableObject +is defined you will find that these "command types" (you can think of +them as slots where custom code can be put) are tied to the at_get, +at_drop and at_desc hooks respecively - this means Evlang scripts put +in the respective slots will ttrigger at the appropriate time. + +There are a few "safe" objects made available out of the box. + + self - reference to object the Evlang handler is defined on + here - shortcut for self.location + caller - reference back to the one triggering the script + scripter - reference to the one creating the script (set by @code) + + There is also the 'evl' object that defines "safe" methods to use. + evl.msg(string, obj=None) # default is the send to caller + evl.msg_contents(string, obj=None) # default is to send to all except caller + delay(delay, function, *args, **kwargs) + attr(obj, attrname=None, attrvalue=None, delete=False) # lock-checking attribute accesser + list() # display all available methods on evl, with docstrings (including your custom additions) + +These all return True after successful execution, which makes +especially the msg* functions easier to use in a conditional. Let's +try it. + + @code crate/look = if caller.key=='Superman' and evl.msg("Your gaze burns a small hole.") or evl.msg("Looks robust!") + +Now look at the crate. :) + +You can (in evlang) use evl.list() to get a list of all methods currently stored on the evl object. For testing, let's +use the same look slot on the crate again. But this time we'll use the /debug mode of @code, which means the script +will be auto-run immediately and we don't have to look at the create to get a result when developing. + +@code/debug create/look = evl.msg(evl.list()) + diff --git a/contrib/evlang/__init__.py b/contrib/evlang/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/evlang/command.py b/contrib/evlang/command.py new file mode 100644 index 0000000000..778367c628 --- /dev/null +++ b/contrib/evlang/command.py @@ -0,0 +1,136 @@ + +""" + +Evlang usage examples + Commands for use with evlang + +Evennia contribution - Griatch 2012 + +The @code command allows to add scripted evlang code to +a ScriptableObject. It will handle access checks. + +""" + +from ev import utils +from ev import default_cmds + +#------------------------------------------------------------ +# Evlang-related commands +# +# Easiest is to add this command to the default cmdset. +# Alternatively one could imagine storing it directly only +# on scriptable objects. +#------------------------------------------------------------ + +class CmdCode(default_cmds.MuxCommand): + """ + add custom code to a scriptable object + + Usage: + @code[/switch] [/ [= ]] + + Switch: + + delete - clear code of given type from the object. + debug - immediately run the given code after adding it. + + This will add custom scripting to an object + which allows such modification. + + must be one of the script types allowed + on the object. Only supplying the command will + return a list of script types possible to add + custom scripts to. + + """ + key = "@code" + locks = "cmd:perm(Builders)" + help_category = "Building" + + def func(self): + "implements the functionality." + caller = self.caller + + if not self.args: + caller.msg("Usage: @code [/ [= ]]") + return + codetype = None + objname = self.lhs + if '/' in self.lhs: + objname, codetype = [part.strip() for part in self.lhs.rsplit("/", 1)] + + obj = self.caller.search(objname) + if not obj: + return + + # get the dicts from db storage for easy referencing + evlang_scripts = obj.db.evlang_scripts + evlang_locks = obj.db.evlang_locks + if not (evlang_scripts != None and evlang_locks and obj.ndb.evlang): + caller.msg("Object %s can not be scripted." % obj.key) + return + + if 'delete' in self.switches: + # clearing a code snippet + if not codetype: + caller.msg("You must specify a code type.") + return + if not codetype in evlang_scripts: + caller.msg("Code type '%s' not found on %s." % (codetype, obj.key)) + return + # this will also update the database + obj.ndb.evlang.delete(codetype) + caller.msg("Code for type '%s' cleared on %s." % (codetype, obj.key)) + return + + if not self.rhs: + if codetype: + scripts = [(name, tup[1], utils.crop(tup[0])) for name, tup in evlang_scripts.items() if name==codetype] + scripts.extend([(name, "--", "--") for name in evlang_locks if name not in evlang_scripts if name==codetype]) + else: + # no type specified. List all scripts/slots on object + print evlang_scripts + scripts = [(name, tup[1], utils.crop(tup[0])) for name, tup in evlang_scripts.items()] + scripts.extend([(name, "--", "--") for name in evlang_locks if name not in evlang_scripts]) + scripts = sorted(scripts, key=lambda p: p[0]) + + table = [["type"] + [tup[0] for tup in scripts], + ["creator"] + [tup[1] for tup in scripts], + ["code"] + [tup[2] for tup in scripts]] + ftable = utils.format_table(table, extra_space=5) + string = "{wEvlang scripts on %s:{n" % obj.key + for irow, row in enumerate(ftable): + if irow == 0: + string += "\n" + "".join("{w%s{n" % col for col in row) + else: + string += "\n" + "".join(col for col in row) + + caller.msg(string) + return + + # we have rhs + codestring = self.rhs + if not codetype in evlang_locks: + caller.msg("Code type '%s' cannot be coded on %s." % (codetype, obj.key)) + return + # check access with the locktype "code" + if not obj.ndb.evlang.lockhandler.check(caller, "code"): + caller.msg("You are not permitted to add code of type %s." % codetype) + return + # we have code access to this type. + oldcode = None + if codetype in evlang_scripts: + oldcode = str(evlang_scripts[codetype][0]) + # this updates the database right away too + obj.ndb.evlang.add(codetype, codestring, scripter=caller) + if oldcode: + caller.msg("{wReplaced{n\n %s\n{wWith{n\n %s" % (oldcode, codestring)) + else: + caller.msg("Code added in '%s':\n %s" % (codetype, codestring)) + if "debug" in self.switches: + # debug mode + caller.msg("{wDebug: running script (look out for errors below) ...{n\n" + "-"*68) + obj.ndb.evlang.run_by_name(codetype, caller, quiet=False) + + + diff --git a/contrib/evlang/evlang.py b/contrib/evlang/evlang.py new file mode 100644 index 0000000000..330075cf31 --- /dev/null +++ b/contrib/evlang/evlang.py @@ -0,0 +1,887 @@ +""" + +EVLANG + +A mini-language for online coding of Evennia + +Evennia contribution - Griatch 2012 + +WARNING: + Restricted python execution is a tricky art, and this module -is- + partly based on blacklisting techniques, which might be vulnerable to + new venues of attack opening up in the future (or existing ones we've + missed). Whereas I/we know of no obvious exploits to this, it is no + guarantee. If you are paranoid about security, consider also using + secondary defences on the OS level such as a jail and highly + restricted execution abilities for the twisted process. So in short, + this should work fine, but use it at your own risk. You have been + warned. + +This module offers a highly restricted execution environment for users +to script objects in an almost-Python language. It's not really a true +sandbox but based on a very stunted version of Python. This not only +restricts obvious things like import statements and other builins, but +also pre-parses the AST tree to completely kill whole families of +functionality. The result is a subset of Python that -should- keep an +untrusted, malicious user from doing bad things to the server. + +An important limitation with this this implementation is a lack of a +timeout check - inside Twisted (and in Python in general) it's very +hard to safely kill a thread with arbitrary code once it's running. So +instead we restrict the most common DOS-attack vectors, such as while +loops, huge power-law assignments as well as function definitions. A +better way would probably be to spawn the runner into a separate +process but that stunts much of the work a user might want to do with +objects (since the current in-memory state of an object has potential +importance in Evennia). If you want to try the subprocess route, you +might want to look into hacking the Evlang handler (below) onto code +from the pysandbox project (https://github.com/haypo/pysandbox). Note +however, that one would probably need to rewrite that to use Twisted's +non-blocking subprocess mechanisms instead. + + +The module holds the "Evlang" handler, which is intended to be the +entry point for adding scripting support anywhere in Evennia. + +By default the execution environment makes the following objects +available (some or all of these may be None depending on how the +code was launched): + caller - a reference to the object triggering the code + scripter - the original creator of the code + self - the object on which the code is defined + here - shortcut to self.location, if applicable + +There is finally a variable "evl" which is a holder object for safe +functions to execute. This object is initiated with the objects above, +to make sure the user does not try to forge the input arguments. See +below the default safe methods defined on it. + +You can add new safe symbols to the execution context by adding +EVLANG_SAFE_CONTEXT to your settings file. This should be a dictionary +with {"name":object} pairs. + +You can also add new safe methods to the evl object. You add them as a +dictionary on the same form to settings.EVLANG_SAFE_METHODS. Remember +that such meethods must be defined properly to be a class method +(notably "self" must be be the first argument on the argument list). + +You can finally define settings.EVLANG_UNALLOWED_SYMBOLS as a list of +python symbol names you specifically want to lock. This will lock both +functions of that name as well as trying to access attributes on +objects with that name (note that these "attributes" have nothing to +do with Evennia's in-database "Attribute" system!). + +""" + +import sys, os, time +import __builtin__ +import inspect, compiler.ast +from twisted.internet import reactor, threads, task +from twisted.internet.defer import inlineCallbacks + +# set up django, if necessary +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +from django.core.management import setup_environ +from game import settings +setup_environ(settings) +from ev import logger + +#------------------------------------------------------------ +# Evennia-specific blocks +#------------------------------------------------------------ + +# specifically forbidden symbols +_EV_UNALLOWED_SYMBOLS = ["attr", "set_attribute", "delete"] +try: _EV_UNALLOWED_SYMBOLS.expand(settings.EVLANG_UNALLOWED_SYMBOLS) +except AttributeError: pass + +# safe methods (including self in args) to make available on +# the evl object +_EV_SAFE_METHODS = {} +try: _EV_SAFE_METHODS.update(settings.EVLANG_SAFE_METHODS) +except AttributeError: pass + +# symbols to make available directly in code +_EV_SAFE_CONTEXT = {"testvar": "This is a safe var!"} +try: _EV_SAFE_CONTEXT.update(settings.EVLANG_SAFE_CONTEXT) +except AttributeError: pass + + +#------------------------------------------------------------ +# Holder object for "safe" function access +#------------------------------------------------------------ + +class Evl(object): + """ + This is a wrapper object for storing safe functions + in a secure way, while offering a few properties for + them to access. This will be made available as the + "evl" property in code. + """ + + def __init__(self, obj=None, caller=None, scripter=None, **kwargs): + "Populate the object with safe properties" + self.obj = obj + self.caller = caller + self.scripter = scripter + self.locatiton = None + if obj and hasattr(obj, "location"): + self.location = obj.location + for key, val in _EV_SAFE_METHODS.items(): + setattr(self.__class__, name, val) + for key, val in kwargs.items(): + setattr(self.__class__, name, val) + + def list(self): + """ + list() + + returns a string listing all methods on the evl object, including doc strings." + """ + # must do it this way since __dict__ is restricted + members = [mtup for mtup in inspect.getmembers(Evl, predicate=inspect.ismethod) + if not mtup[0].startswith("_")] + string = "\n".join(["{w%s{n\n %s" % (mtup[0], mtup[1].func_doc.strip()) for mtup in members]) + return string + + def msg(self, string, obj=None): + """ + msg(string, obj=None) + + Sends message to obj or to caller if obj is not defined.. + """ + if not obj: + obj = self.caller + obj.msg(string) + return True + + def msg_contents(self, string, obj=None): + """ + msg_contents(string, obj=None): + + Sends message to the contents of obj, or to content of self if obj is not defined. + """ + if not obj: + obj = self.obj + obj.msg_contents(string, exclude=[obj]) + return True + + def msg_here(self, string, obj=None): + """ + msg_here(string, obj=None) + + Sends to contents of obj.location, or to self.location if obj is not defined. + """ + if obj and hasattr(obj, "location"): + here = obj.location + else: + here = self.location + if here: + here.msg_contents(string) + + def delay(self, seconds, function, *args, **kwargs): + """ + delay(seconds, function, *args, **kwargs): + + Delay execution of function(*args, **kwargs) for up to 120 seconds. + + Error messages are relayed to caller unless a specific keyword + 'errobj' is supplied pointing to another object to receiver errors. + """ + # handle the special error-reporting object + errobj = self.caller + if "errobj" in kwargs: + errobj = kwargs["errobj"] + del kwargs["errobj"] + # set up some callbacks for delayed execution + def errback(f, errobj): + if errobj: + try: f = f.getErrorMessage() + except: pass + errobj.msg("EVLANG delay error: " + str(f)) + def runfunc(func, *args, **kwargs): + threads.deferToThread(func, *args, **kwargs).addErrback(errback, errobj) + # get things going + if seconds <= 120: + task.deferLater(reactor, seconds, runfunc, function, *args, **kwargs).addErrback(errback, errobj) + else: + raise EvlangError("delay() can only delay for a maximum of 120 seconds (got %ss)." % seconds ) + return True + + def attr(self, obj, attrname=None, value=None, delete=False): + """ + attr(obj, attrname=None, value=None, delete=False) + + Access and edit database Attributes on obj. if only obj + is given, return list of Attributes on obj. If attrname + is given, return that Attribute's value only. If also + value is given, set the attribute to that value. The + delete flag will delete the given attrname from the object. + + Access is checked for all operations. The method will return + the attribute value or True if the operation was a success, + None otherwise. + """ + print obj, hasattr(obj, "secure_attr") + if hasattr(obj, "secure_attr"): + return obj.secure_attr(self.caller, attrname, value, delete=False, + default_access_read=True, default_access_edit=False, + default_access_create=True) + return False + + +#------------------------------------------------------------ +# Evlang class handler +#------------------------------------------------------------ + +class EvlangError(Exception): + "Error for evlang handler" + pass + +class Evlang(object): + """ + This is a handler for launching limited execution Python scripts. + + Normally this handler is stored on an object and will then give + access to basic operations on the object. It can however also be + run stand-alone. + + If running on an object, it should normally be initiated in the + object's at_server_start() method and assigned to a property + "evlang" (or similar) for easy access. It will then use the object + for storing a dictionary of available evlang scripts (default name + of this attribute is "evlang_scripts"). + + Note: This handler knows nothing about access control. To get that + one needs to append a LockHandler as "lockhandler" at creation + time, as well as arrange for commands to do access checks of + suitable type. Once methods on this handler are called, access is + assumed to be granted. + + """ + def __init__(self, obj=None, scripts=None, storage_attr="evlang_scripts", safe_context=None, safe_timeout=2): + """ + Setup of the Evlang handler. + + Input: + obj - a reference to the object this handler is defined on. If not set, handler will operate stand-alone. + scripts = dictionary {scriptname, (codestring, callerobj), ...} where callerobj can be None. + evlang_storage_attr - if obj is given, will look for a dictionary {scriptname, (codestring, callerobj)...} + stored in this given attribute name on that object. + safe_funcs - dictionary of {funcname:funcobj, ...} to make available for the execution environment + safe_timeout - the time we let a script run. If it exceeds this time, it will be blocked from running again. + + """ + self.obj = obj + self.evlang_scripts = {} + self.safe_timeout = safe_timeout + self.evlang_storage_attr = storage_attr + if scripts: + self.evlang_scripts.update(scripts) + if self.obj: + self.evlang_scripts.update(obj.attr(storage_attr)) + self.safe_context = _EV_SAFE_CONTEXT # set by default + settings + if safe_context: + self.safe_context.update(safe_context) + self.timedout_codestrings = [] + + def msg(self, string, scripter=None, caller=None): + """ + Try to send string to a receiver. Returns False + if no receiver was found. + """ + if scripter: + scripter.msg(string) + elif caller: + caller.msg(string) + elif self.obj: + self.obj.msg(string) + else: + return False + return True + + def start_timer(self, timeout, codestring, caller, scripter): + """ + Start a timer to check how long an execution has lasted. + Returns a deferred, which should be cancelled when the + code does finish. + """ + def alarm(codestring): + "store the code of too-long-running scripts" + self.timedout_codestrings.append(codestring) + err = "Evlang code '%s' exceeded allowed execution time (>%ss)." % (codestring, timeout) + logger.log_errmsg("EVLANG time exceeded: caller: %s, scripter: %s, code: %s" % (caller, scripter, codestring)) + if not self.msg(err, scripter, caller): + raise EvlangError(err) + def errback(f): + "We need an empty errback, to catch the traceback of defer.cancel()" + pass + return task.deferLater(reactor, timeout, alarm, codestring).addErrback(errback) + def stop_timer(self, _, deferred): + "Callback for stopping a previously started timer. Cancels the given deferred." + deferred.cancel() + + @inlineCallbacks + def run(self, codestring, caller=None, scripter=None): + """ + run a given code string. + + codestring - the actual code to execute. + scripter - the creator of the script. Preferentially sees error messages + caller - the object triggering the script - sees error messages if no scripter is given + """ + + # catching previously detected long-running code + if codestring in self.timedout_codestrings: + err = "Code '%s' previously failed with a timeout. Please rewrite code." % codestring + if not self.msg(err, scripter, caller): + raise EvlangError(err) + return + + # dynamically setup context, then overload with custom additions + location = None + if self.obj: + location = self.obj.location + context = {"self":self.obj, + "caller":caller, + "scripter": scripter, + "here": location, + "evl": Evl(self.obj, caller, scripter)} + context.update(self.safe_context) + + # launch the runner in a separate thread, tracking how long it runs. + timer = self.start_timer(self.safe_timeout, codestring, scripter, caller) + try: + yield threads.deferToThread(limited_exec, codestring, context=context, + timeout_secs=self.safe_timeout).addCallback(self.stop_timer, timer) + except Exception, e: + self.stop_timer(None, timer) + if not self.msg(e, scripter, caller): + raise e + + def run_by_name(self, scriptname, caller=None, quiet=True): + """ + Run a script previously stored on the handler, identified by scriptname. + + scriptname - identifier of the stored script + caller - optional reference to the object triggering the script. + quiet - will not raise error if scriptname is not found. + + All scripts run will have access to the self, caller and here variables. + """ + scripter = None + try: + codestring, scripter = self.evlang_scripts[scriptname] + except KeyError: + if quiet: + return + errmsg = "Found no script with the name '%s'." % scriptname + if not self.msg(errmsg, scripter=None, caller=caller): + raise EvlangError(errmsg) + return + # execute code + self.run(codestring, caller, scripter) + + + def add(self, scriptname, codestring, scripter=None): + """ + Add a new script to the handler. This will also save the + script properly. This is used also to update scripts when + debugging. + """ + self.evlang_scripts[scriptname] = (codestring, scripter) + if self.obj: + # save to database + self.obj.attr(self.evlang_storage_attr, self.evlang_scripts) + + def delete(self, scriptname): + """ + Permanently remove script from object. + """ + if scriptname in self.evlang_scripts: + del self.evlang_scripts[scriptname] + if self.obj: + # update change to database + self.obj.attr(self.evlang_storage_attr, self.evlang_scripts) + + +#---------------------------------------------------------------------- + +# Limited Python evaluation. + +# Based on PD recipe by Babar K. Zafar +# http://code.activestate.com/recipes/496746/ + +# Expanded specifically for Evennia by Griatch +# - some renaming/cleanup +# - limited size of power expressions +# - removed print (use msg() instead) +# - blocking certain function calls +# - removed assignment of properties - this is too big of a security risk. +# One needs to us a safe function to change propertes. +# - removed thread-based check for execution time - it doesn't work +# embedded in twisted/python. +# - removed while, since it's night impossible to properly check compile +# time in an embedded Python thread (or rather, it's possible, but +# there is no way to cancel the thread anyway). while is an easy way +# to create an infinite loop. +#---------------------------------------------------------------------- + + + +#---------------------------------------------------------------------- +# Module globals. +#---------------------------------------------------------------------- + +# Toggle module level debugging mode. +DEBUG = False + +# List of all AST node classes in compiler/ast.py. +ALL_AST_NODES = \ + set([name for (name, obj) in inspect.getmembers(compiler.ast) + if inspect.isclass(obj) and issubclass(obj, compiler.ast.Node)]) + +# List of all builtin functions and types (ignoring exception classes). +ALL_BUILTINS = \ + set([name for (name, obj) in inspect.getmembers(__builtin__) + if (inspect.isbuiltin(obj) or name in ('True', 'False', 'None') or + (inspect.isclass(obj) and not issubclass(obj, BaseException)))]) + +#---------------------------------------------------------------------- +# Utilties. +#---------------------------------------------------------------------- + +def classname(obj): + return obj.__class__.__name__ + +def is_valid_ast_node(name): + return name in ALL_AST_NODES + +def is_valid_builtin(name): + return name in ALL_BUILTINS + +def get_node_lineno(node): + return (node.lineno) and node.lineno or 0 + + +#---------------------------------------------------------------------- +# Restricted AST nodes & builtins. +#---------------------------------------------------------------------- + +# Deny evaluation of code if the AST contain any of the following nodes: +UNALLOWED_AST_NODES = set([ +# 'Add', 'And', + 'AssAttr', +# 'AssList', +# 'AssName', +# 'AssTuple', +# 'Assert', 'Assign', 'AugAssign', + 'Backquote', +# 'Bitand', 'Bitor', 'Bitxor', 'Break', +# 'CallFunc', 'Class', 'Compare', 'Const', 'Continue', +# 'Decorators', 'Dict', 'Discard', 'Div', +# 'Ellipsis', 'EmptyNode', + 'Exec', +# 'Expression', 'FloorDiv', +# 'For', + 'From', + 'Function', +# 'GenExpr', 'GenExprFor', 'GenExprIf', 'GenExprInner', +# 'Getattr', 'Global', 'If', + 'Import', +# 'Invert', +# 'Keyword', 'Lambda', 'LeftShift', +# 'List', 'ListComp', 'ListCompFor', 'ListCompIf', 'Mod', +# 'Module', +# 'Mul', 'Name', 'Node', 'Not', 'Or', 'Pass', 'Power', + 'Print', 'Printnl', + 'Raise', +# 'Return', 'RightShift', 'Slice', 'Sliceobj', +# 'Stmt', 'Sub', 'Subscript', + 'TryExcept', 'TryFinally', +# 'Tuple', 'UnaryAdd', 'UnarySub', + 'While', +# 'Yield' +]) + +# Deny evaluation of code if it tries to access any of the following builtins: +UNALLOWED_BUILTINS = set([ + '__import__', +# 'abs', 'apply', 'basestring', 'bool', 'buffer', +# 'callable', 'chr', 'classmethod', 'cmp', 'coerce', + 'compile', +# 'complex', + 'delattr', +# 'dict', + 'dir', +# 'divmod', 'enumerate', + 'eval', 'execfile', 'file', +# 'filter', 'float', 'frozenset', + 'getattr', 'globals', 'hasattr', +# 'hash', 'hex', 'id', + 'input', +# 'int', + 'intern', +# 'isinstance', 'issubclass', 'iter', +# 'len', 'list', + 'locals', +# 'long', 'map', 'max', + 'memoryview', +# 'min', 'object', 'oct', + 'open', +# 'ord', 'pow', 'property', 'range', + 'raw_input', +# 'reduce', + 'reload', +# 'repr', 'reversed', 'round', 'set', + 'setattr', +# 'slice', 'sorted', 'staticmethod', 'str', 'sum', + 'super', +# 'tuple', + 'type', +# 'unichr', 'unicode', + 'vars', +# 'xrange', 'zip' +]) + +# extra validation whitelist-style to avoid new versions of Python creeping +# in with new unsafe things +SAFE_BUILTINS = set([ + 'False', 'None', 'True', 'abs', 'all', 'any', 'apply', 'basestring', + 'bin', 'bool', 'buffer', 'bytearray', 'bytes', 'callable', 'chr', 'classmethod', + 'cmp', 'coerce', 'complex', 'dict', 'divmod', 'enumerate', 'filter', + 'float', 'format', 'frozenset', 'hash', 'hex', 'id', 'int', + 'isinstance', 'issubclass', 'iter', 'len', 'list', 'long', 'map', 'max', 'min', + 'next', 'object', 'oct', 'ord', 'pow', 'print', 'property', 'range', 'reduce', + 'repr', 'reversed', 'round', 'set', 'slice', 'sorted', 'staticmethod', 'str', + 'sum', 'tuple', 'unichr', 'unicode', 'xrange', 'zip' ]) + +for ast_name in UNALLOWED_AST_NODES: + assert(is_valid_ast_node(ast_name)) +for name in UNALLOWED_BUILTINS: + assert(is_valid_builtin(name)) +def _cross_match_whitelist(): + "check the whitelist's completeness" + available = ALL_BUILTINS - UNALLOWED_BUILTINS + diff = available.difference(SAFE_BUILTINS) + assert not diff, diff # check so everything not disallowed is in safe + diff = SAFE_BUILTINS.difference(available) + assert not diff, diff # check so everything everything in safe is in not-disallowed +_cross_match_whitelist() + +def is_unallowed_ast_node(kind): + return kind in UNALLOWED_AST_NODES + +def is_unallowed_builtin(name): + return name in UNALLOWED_BUILTINS + +#---------------------------------------------------------------------- +# Restricted attributes. +#---------------------------------------------------------------------- + +# In addition to these we deny access to all lowlevel attrs (__xxx__). +UNALLOWED_ATTR = [ + 'im_class', 'im_func', 'im_self', + 'func_code', 'func_defaults', 'func_globals', 'func_name', + 'tb_frame', 'tb_next', + 'f_back', 'f_builtins', 'f_code', 'f_exc_traceback', + 'f_exc_type', 'f_exc_value', 'f_globals', 'f_locals'] +UNALLOWED_ATTR.extend(_EV_UNALLOWED_SYMBOLS) + +def is_unallowed_attr(name): + return (name[:2] == '__' and name[-2:] == '__') or \ + (name in UNALLOWED_ATTR) + + +#---------------------------------------------------------------------- +# LimitedExecVisitor. +#---------------------------------------------------------------------- + +class LimitedExecError(object): + """ + Base class for all which occur while walking the AST. + + Attributes: + errmsg = short decription about the nature of the error + lineno = line offset to where error occured in source code + """ + def __init__(self, errmsg, lineno): + self.errmsg, self.lineno = errmsg, lineno + def __str__(self): + return "line %d : %s" % (self.lineno, self.errmsg) + +class LimitedExecASTNodeError(LimitedExecError): + "Expression/statement in AST evaluates to a restricted AST node type." + pass +class LimitedExecBuiltinError(LimitedExecError): + "Expression/statement in tried to access a restricted builtin." + pass +class LimitedExecAttrError(LimitedExecError): + "Expression/statement in tried to access a restricted attribute." + pass + +class LimitedExecVisitor(object): + """ + Data-driven visitor which walks the AST for some code and makes + sure it doesn't contain any expression/statements which are + declared as restricted in 'UNALLOWED_AST_NODES'. We'll also make + sure that there aren't any attempts to access/lookup restricted + builtin declared in 'UNALLOWED_BUILTINS'. By default we also won't + allow access to lowlevel stuff which can be used to dynamically + access non-local envrioments. + + Interface: + walk(ast) = validate AST and return True if AST is 'safe' + + Attributes: + errors = list of LimitedExecError if walk() returned False + + Implementation: + + The visitor will automatically generate methods for all of the + available AST node types and redirect them to self.ok or self.fail + reflecting the configuration in 'UNALLOWED_AST_NODES'. While + walking the AST we simply forward the validating step to each of + node callbacks which take care of reporting errors. + """ + + def __init__(self): + "Initialize visitor by generating callbacks for all AST node types." + self.errors = [] + for ast_name in ALL_AST_NODES: + # Don't reset any overridden callbacks. + if getattr(self, 'visit' + ast_name, None): + continue + if is_unallowed_ast_node(ast_name): + setattr(self, 'visit' + ast_name, self.fail) + else: + setattr(self, 'visit' + ast_name, self.ok) + + def walk(self, ast): + "Validate each node in AST and return True if AST is 'safe'." + self.visit(ast) + return self.errors == [] + + def visit(self, node, *args): + "Recursively validate node and all of its children." + fn = getattr(self, 'visit' + classname(node)) + if DEBUG: self.trace(node) + fn(node, *args) + for child in node.getChildNodes(): + self.visit(child, *args) + + def visitName(self, node, *args): + "Disallow any attempts to access a restricted builtin/attr." + name = node.getChildren()[0] + lineno = get_node_lineno(node) + if is_unallowed_builtin(name): + self.errors.append(LimitedExecBuiltinError( \ + "access to builtin '%s' is denied" % name, lineno)) + elif is_unallowed_attr(name): + self.errors.append(LimitedExecAttrError( \ + "access to attribute '%s' is denied" % name, lineno)) + + def visitGetattr(self, node, *args): + "Disallow any attempts to access a restricted attribute." + attrname = node.attrname + try: + name = node.getChildren()[0].name + except Exception: + name = "" + lineno = get_node_lineno(node) + if attrname == 'attr' and name =='evl': + pass + elif is_unallowed_attr(attrname): + self.errors.append(LimitedExecAttrError( \ + "access to attribute '%s' is denied" % attrname, lineno)) + + def visitAssName(self, node, *args): + "Disallow attempts to delete an attribute or name" + if node.flags == 'OP_DELETE': + self.fail(node, *args) + + def visitPower(self, node, *args): + "Make sure power-of operations don't get too big" + if node.left.value > 1000000 or node.right.value > 10000: + lineno = get_node_lineno(node) + self.errors.append(LimitedExecAttrError( \ + "power law index too big - restricted", lineno)) + + def ok(self, node, *args): + "Default callback for 'harmless' AST nodes." + pass + + def fail(self, node, *args): + "Default callback for unallowed AST nodes." + lineno = get_node_lineno(node) + self.errors.append(LimitedExecASTNodeError( \ + "execution of '%s' statements is denied" % classname(node), + lineno)) + + def trace(self, node): + "Debugging utility for tracing the validation of AST nodes." + print classname(node) + for attr in dir(node): + if attr[:2] != '__': + print ' ' * 4, "%-15.15s" % attr, getattr(node, attr) + +#---------------------------------------------------------------------- +# Safe 'eval' replacement. +#---------------------------------------------------------------------- + +class LimitedExecException(Exception): + "Base class for all safe-eval related errors." + pass + +class LimitedExecCodeException(LimitedExecException): + """ + Exception class for reporting all errors which occured while + validating AST for source code in limited_exec(). + + Attributes: + code = raw source code which failed to validate + errors = list of LimitedExecError + """ + def __init__(self, code, errors): + self.code, self.errors = code, errors + def __str__(self): + return '\n'.join([str(err) for err in self.errors]) + +class LimitedExecContextException(LimitedExecException): + """ + Exception class for reporting unallowed objects found in the dict + intended to be used as the local enviroment in safe_eval(). + + Attributes: + keys = list of keys of the unallowed objects + errors = list of strings describing the nature of the error + for each key in 'keys' + """ + def __init__(self, keys, errors): + self.keys, self.errors = keys, errors + def __str__(self): + return '\n'.join([str(err) for err in self.errors]) + +class LimitedExecTimeoutException(LimitedExecException): + """ + Exception class for reporting that code evaluation execeeded + the given timelimit. + + Attributes: + timeout = time limit in seconds + """ + def __init__(self, timeout): + self.timeout = timeout + def __str__(self): + return "Timeout limit execeeded (%s secs) during exec" % self.timeout + + +def validate_context(context): + "Checks a supplied context for dangerous content" + ctx_errkeys, ctx_errors = [], [] + for (key, obj) in context.items(): + if inspect.isbuiltin(obj): + ctx_errkeys.append(key) + ctx_errors.append("key '%s' : unallowed builtin %s" % (key, obj)) + if inspect.ismodule(obj): + ctx_errkeys.append(key) + ctx_errors.append("key '%s' : unallowed module %s" % (key, obj)) + + if ctx_errors: + raise LimitedExecContextException(ctx_errkeys, ctx_errors) + return True + +def validate_code(codestring): + "validate a code string" + # prepare the code tree for checking + ast = compiler.parse(codestring) + checker = LimitedExecVisitor() + + # check code tree, then execute in a time-restricted environment + if not checker.walk(ast): + raise LimitedExecCodeException(codestring, checker.errors) + return True + +def limited_exec(code, context = {}, timeout_secs = 2): + """ + Validate source code and make sure it contains no unauthorized + expression/statements as configured via 'UNALLOWED_AST_NODES' and + 'UNALLOWED_BUILTINS'. By default this means that code is not + allowed import modules or access dangerous builtins like 'open' or + 'eval'. If code is considered 'safe' it will be executed via + 'exec' using 'context' as the global environment. More details on + how code is executed can be found in the Python Reference Manual + section 6.14 (ignore the remark on '__builtins__'). The 'context' + enviroment is also validated and is not allowed to contain modules + or builtins. The following exception will be raised on errors: + + if 'context' contains unallowed objects = + LimitedExecContextException + + if code didn't validate and is considered 'unsafe' = + LimitedExecCodeException + + if code did not execute within the given timelimit = + LimitedExecTimeoutException + """ + # run code only after validation has completed + if validate_context(context) and validate_code(code): + exec code in context + + + +#---------------------------------------------------------------------- +# Basic tests. +#---------------------------------------------------------------------- + +import unittest + +class TestLimitedExec(unittest.TestCase): + def test_builtin(self): + # attempt to access a unsafe builtin + self.assertRaises(LimitedExecException, + limited_exec, "open('test.txt', 'w')") + + def test_getattr(self): + # attempt to get arround direct attr access + self.assertRaises(LimitedExecException, \ + limited_exec, "getattr(int, '__abs__')") + + def test_func_globals(self): + # attempt to access global enviroment where fun was defined + self.assertRaises(LimitedExecException, \ + limited_exec, "def x(): pass; print x.func_globals") + + def test_lowlevel(self): + # lowlevel tricks to access 'object' + self.assertRaises(LimitedExecException, \ + limited_exec, "().__class__.mro()[1].__subclasses__()") + + def test_timeout_ok(self): + # attempt to exectute 'slow' code which finishes within timelimit + def test(): time.sleep(2) + env = {'test':test} + limited_exec("test()", env, timeout_secs = 5) + + def test_timeout_exceed(self): + # attempt to exectute code which never teminates + self.assertRaises(LimitedExecException, \ + limited_exec, "while 1: pass") + + def test_invalid_context(self): + # can't pass an enviroment with modules or builtins + env = {'f' : __builtins__.open, 'g' : time} + self.assertRaises(LimitedExecException, \ + limited_exec, "print 1", env) + + def test_callback(self): + # modify local variable via callback + self.value = 0 + def test(): self.value = 1 + env = {'test':test} + limited_exec("test()", env) + self.assertEqual(self.value, 1) + +if __name__ == "__main__": + unittest.main() + diff --git a/contrib/evlang/examples.py b/contrib/evlang/examples.py new file mode 100644 index 0000000000..60449a3582 --- /dev/null +++ b/contrib/evlang/examples.py @@ -0,0 +1,73 @@ +""" + +Evlang - usage examples + +Craftable object with matching command + +Evennia contribution - Griatch 2012 + +""" + +from ev import create_object +from ev import default_cmds +from contrib.evlang.objects import ScriptableObject + +#------------------------------------------------------------ +# Example for creating a scriptable object with a custom +# "crafting" command that sets coding restrictions on the +# object. +#------------------------------------------------------------ + +class CmdCraftScriptable(default_cmds.MuxCommand): + """ + craft a scriptable object + + Usage: + @craftscriptable + + """ + key = "@craftscriptable" + locks = "cmd:perm(Builder)" + help_category = "Building" + + def func(self): + "Implements the command" + caller = self.caller + if not self.args: + caller.msg("Usage: @craftscriptable ") + return + objname = self.args.strip() + obj = create_object(CraftedScriptableObject, key=objname, location=caller.location) + if not obj: + caller.msg("There was an error creating %s!" % objname) + return + # set locks on the object restrictive coding only to us, the creator. + obj.db.evlang_locks = {"get":"code:id(%s) or perm(Wizards)" % caller.dbref, + "drop":"code:id(%s) or perm(Wizards)" % caller.dbref, + "look": "code:id(%s) or perm(Wizards)" % caller.dbref} + caller.msg("Crafted %s. Use @desc and @code to customize it." % objname) + + +class CraftedScriptableObject(ScriptableObject): + """ + An object which allows customization of what happens when it is + dropped, taken or examined. It is meant to be created with the + special command CmdCraftScriptable above, for example as part of + an in-game "crafting" operation. It can henceforth be expanded + with custom scripting with the @code command (and only the crafter + (and Wizards) will be able to do so). + + Allowed Evlang scripts: + "get" + "drop" + "look" + """ + def at_get(self, getter): + "called when object is picked up" + self.ndb.evlang.run_by_name("get", getter) + def at_drop(self, dropper): + "called when object is dropped" + self.ndb.evlang.run_by_name("drop", dropper) + def at_desc(self, looker): + "called when object is being looked at." + self.ndb.evlang.run_by_name("look", looker) diff --git a/contrib/evlang/objects.py b/contrib/evlang/objects.py new file mode 100644 index 0000000000..6e147229af --- /dev/null +++ b/contrib/evlang/objects.py @@ -0,0 +1,97 @@ +""" + +Evlang usage examples + scriptable Evennia base typeclass and @code command + +Evennia contribution - Griatch 2012 + +The ScriptableObject typeclass initiates the Evlang handler on +itself as well as sets up a range of commands to +allow for scripting its functionality. It sets up an access +control system using the 'code' locktype to limit access to +these codes. + +The @code command allows to add scripted evlang code to +a ScriptableObject. It will handle access checks. + + +There are also a few examples of usage - a simple Room +object that has scriptable behaviour when it is being entered +as well as a more generic template for a Craftable object along +with a base crafting command to create it and set it up with +access restrictions making it only scriptable by the original +creator. + +""" + +from contrib.evlang import evlang +from src.locks.lockhandler import LockHandler +from ev import Object + + +#------------------------------------------------------------ +# Typeclass bases +#------------------------------------------------------------ + +class ScriptableObject(Object): + """ + Base class for an object possible to script. By default it defines + no scriptable types. + """ + + def init_evlang(self): + """ + Initialize an Evlang handler with access control. Requires + the evlang_locks attribute to be set to a dictionary with + {name:lockstring, ...}. + """ + evl = evlang.Evlang(self) + evl.lock_storage = "" + evl.lockhandler = LockHandler(evl) + for lockstring in self.db.evlang_locks.values(): + evl.lockhandler.add(lockstring) + return evl + + def at_object_creation(self): + """ + We add the Evlang handler and sets up + the needed properties. + """ + # this defines the available types along with the lockstring + # restricting access to them. Anything not defined in this + # dictionary is forbidden to script at all. Just because + # a script type is -available- does not mean there is any + # code yet in that slot! + self.db.evlang_locks = {} + # This stores actual code snippets. Only code with codetypes + # matching the keys in db.evlang_locks will work. + self.db.evlang_scripts = {} + # store Evlang handler non-persistently + self.ndb.evlang = self.init_evlang() + + def at_init(self): + "We must also re-add the handler at server reboots" + self.ndb.evlang = self.init_evlang() + +# Example object types + +from ev import Room +class ScriptableRoom(Room, ScriptableObject): + """ + A room that is scriptable as well as allows users + to script what happens when users enter it. + + Allowed scripts: + "enter" (allowed to be modified by all builders) + + """ + def at_object_creation(self): + "initialize the scriptable object" + self.db.evlang_locks = {"enter": "code:perm(Builders)"} + self.db.evlang_scripts = {} + self.ndb.evlang = self.init_evlang() + + def at_object_receive(self, obj, source_location): + "fires a script of type 'enter' (no error if it's not defined)" + self.ndb.evlang.run_by_name("enter", obj) +