{"id":45543405,"url":"https://github.com/answerdotai/safepyrun","last_synced_at":"2026-06-03T01:01:36.867Z","repository":{"id":340041946,"uuid":"1164221601","full_name":"AnswerDotAI/safepyrun","owner":"AnswerDotAI","description":"Safe(ish) running of python code","archived":false,"fork":false,"pushed_at":"2026-06-02T23:38:47.000Z","size":749,"stargazers_count":10,"open_issues_count":2,"forks_count":2,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-03T00:00:07.868Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"https://answerdotai.github.io/safepyrun/","language":"Jupyter Notebook","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/AnswerDotAI.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-22T20:17:10.000Z","updated_at":"2026-06-02T23:38:51.000Z","dependencies_parsed_at":"2026-05-08T12:04:53.436Z","dependency_job_id":null,"html_url":"https://github.com/AnswerDotAI/safepyrun","commit_stats":null,"previous_names":["answerdotai/safepyrun"],"tags_count":25,"template":false,"template_full_name":null,"purl":"pkg:github/AnswerDotAI/safepyrun","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AnswerDotAI%2Fsafepyrun","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AnswerDotAI%2Fsafepyrun/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AnswerDotAI%2Fsafepyrun/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AnswerDotAI%2Fsafepyrun/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/AnswerDotAI","download_url":"https://codeload.github.com/AnswerDotAI/safepyrun/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/AnswerDotAI%2Fsafepyrun/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33843611,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-02T02:00:07.132Z","response_time":109,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-02-23T04:28:34.721Z","updated_at":"2026-06-03T01:01:36.859Z","avatar_url":"https://github.com/AnswerDotAI.png","language":"Jupyter Notebook","funding_links":[],"categories":[],"sub_categories":[],"readme":"# safepyrun\n\n\n\u003c!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! --\u003e\n\n*safepyrun* is an allowlist-based Python sandbox that lets LLMs execute\ncode safely(ish) in your real environment. Instead of isolating code in\na container (which cuts it off from the libraries, data, and tools it\nactually needs) safepyrun runs in-process with controlled access to a\ncurated subset of Python’s stdlib, plus any functions you explicitly opt\nin.\n\nIt’s the Python counterpart to\n[safecmd](https://github.com/AnswerDotAI/safecmd), which does much the\nsame thing for bash.\n\n## Installation\n\nInstall from [pypi](https://pypi.org/project/safepyrun/)\n\n``` sh\n$ pip install safepyrun\n```\n\n## Background\n\nWhen an LLM needs to run code on your behalf, the standard advice is to\nsandbox it in a container. The problem is that the whole reason you want\nthe LLM running code is so it can interact with your environment – your\nfiles, your libraries, your running processes, your data. A\ncontainerised sandbox either can’t access any of that, or it requires\ncomplex volume mounts and dependency mirroring that recreate your\nenvironment inside the container.\n\nYou could just `exec` the LLM’s code directly in your process, which\nwould give full access to everything… but “everything” includes\n[`shutil.rmtree`](https://docs.python.org/3/library/shutil.html#shutil.rmtree),\n[`os.remove`](https://docs.python.org/3/library/os.html#os.remove),\n`subprocess.run(\"rm -rf /\")`, etc!\n\nsafepyrun takes a middle path. It runs the LLM’s code in your real\nPython process, with access to your real objects, but interposes an\nallowlist that controls which callables are accessible. The curated\ndefault list covers a large and useful subset of the standard library\n(string manipulation, math, JSON parsing, path inspection, data\nstructures, and so on) while excluding anything that writes to the\nfilesystem, spawns processes, or modifies system state. You can extend\nthe list for your own functions.\n\nThe mechanism behind safepyrun is\n[RestrictedPython](https://restrictedpython.readthedocs.io/), a\nlong-standing project that compiles Python source code into a modified\nAST (Abstract Syntax Tree) where every attribute access, item access,\nand iteration is routed through hook functions. This means that when the\nLLM’s code does `obj.method()`, it doesn’t go directly to `method` – it\ngoes through a gatekeeper that checks whether that callable is on the\nallowlist. The same applies to `getattr`, `getitem`, and `iter`, so\nthere’s no easy way to accidentally reach a dangerous function through\nindirect access. safepyrun supplies these hook functions, wiring them up\nto an allowlist of permitted callables.\n\nBecause a lot of modern Python code (and many LLM tool-calling\nframeworks) is async, safepyrun also depends on\n[restrictedpython-async](https://github.com/AnswerDotAI/restrictedpython-async),\nwhich extends RestrictedPython to handle `await`, `async for`, and\n`async with` expressions.\n\nA lot of the online discussion around RestrictedPython suggests it’s not\nreally useful for sandboxing, and that’s true if you’re trying to block\na determined adversary. But an LLM is not a determined adversary. It’s a\nwell-meaning but occasionally clumsy collaborator. The threat model is\ncompletely different: you don’t need to prevent deliberate escape\nattempts, you need to make it very unlikely that a hallucinated cleanup\nstep or a misunderstood request causes damage. This is the same\n“safe-ish” philosophy used in\n[safecmd](https://github.com/AnswerDotAI/safecmd) for bash.\n\nOnce you internalise this, the design space opens up. It’s actually fine\nfor the LLM to read files, access the internet via `httpx`, parse data,\nand call into your libraries. The things you want to prevent are writes\nto the filesystem, spawning processes, and overwriting important state.\nRestrictedPython gives us the mechanism to enforce this: it rewrites the\nAST to intercept attribute access, iteration, and item access, so that\nevery callable goes through an allowlist check.\n\nThe allowlist has two tiers. First, a curated subset of the standard\nlibrary that has been audited once so every user doesn’t have to repeat\nthe work: things like `re`, `json`, `itertools`, `math`, `collections`,\n`pathlib` (read-only methods), and many more. Second, user-extended\nfunctions registered via `allow()`, so you can opt in your own project’s\nfunctions and methods. Symbols the LLM creates are exported back to the\ncaller’s namespace by default, unless they would shadow an existing\ncallable or module. Names ending with `_` (like `result_`) are always\nexported, even if they shadow. Exported callables must still be\nregistered with `allow()` to be callable in subsequent sandbox calls.\n\n## Usage\n\n``` python\nfrom safepyrun import *\nfrom pyskills import *\n```\n\nThe main entry point is `pyrun = RunPython()`, which returns an async\nfunction that takes a string of Python code and executes it in the\nsandbox. The last expression in the code is returned as the result, and\nany `print()` output is captured separately. Errors are caught and\nreported rather than crashing the caller.\n\n``` python\npyrun = RunPython()\n```\n\n``` python\nawait pyrun('1+1')\n```\n\n    2\n\nYou can mix `print()` output with a return value. The printed output\ngoes to the `stdout` key, and the last expression becomes `result`:\n\n``` python\nawait pyrun('print(\"hello\"); 1+1')\n```\n\n    hello\n\n    2\n\nModules can be imported. stderr is also captured:\n\n``` python\nawait pyrun('''\nimport warnings\nwarnings.warn('a warning')\n\"ok\"\n''')\n```\n\n    \u003cpyrun_3\u003e:2: UserWarning: a warning\n      warnings.warn('a warning')\n\n    'ok'\n\nA large subset of the standard library is available out of the box –\nthings like `re`, `json`, `math`, `itertools`, `collections`, `pathlib`\n(read-only methods), and many more. These have been audited once so that\nevery user doesn’t have to repeat the work:\n\n``` python\nawait pyrun('import re; re.findall(r\"\\\\d+\", \"there are 3 cats and 10 dogs\")')\n```\n\n    ['3', '10']\n\nThe default allowlist covers text and data processing (`re`, `json`,\n`csv`, `html`, `textwrap`, `string`, `difflib`, `unicodedata`), math and\nnumerics (`math`, `cmath`, `statistics`, `decimal`, `fractions`,\n`random`, `operator`), data structures (`collections`, `heapq`,\n`bisect`, plus methods on all the built-in types), iteration and\nfunctional tools (`itertools`, `functools`), read-only filesystem access\n(`pathlib`,\n[`os.path`](https://docs.python.org/3/library/os.path.html#module-os.path),\n`fnmatch`), date and time (`datetime`, `time`), URL handling and\nread-only HTTP\n([`urllib.parse`](https://docs.python.org/3/library/urllib.parse.html#module-urllib.parse),\n`httpx.get`, `ipaddress`), encoding and serialization (`base64`,\n`binascii`, `hashlib`, `zlib`, `pickle`, `struct`), introspection\n(`inspect`, `ast`, `keyword`,\n[`sys.getsizeof`](https://docs.python.org/3/library/sys.html#sys.getsizeof)),\nXML parsing\n([`xml.etree.ElementTree`](https://docs.python.org/3/library/xml.etree.elementtree.html#module-xml.etree.ElementTree)),\nand various utilities (`contextlib`, `copy`, `dataclasses`, `enum`,\n`secrets`, `uuid`, `pprint`, `shlex`, `colorsys`, `traceback`).\n\n### The `allow()` function\n\nFunctions you define yourself or import from third-party packages are\nnot automatically available. If the sandbox encounters an unregistered\ncallable, it raises an error.\n\nTo make a function available, register it with `allow()`:\n\n``` python\ndef greet(name): return f\"Hello, {name}!\"\n```\n\n``` python\nallow(greet) # Or use @allow decorator\nawait pyrun('greet(\"World\")')\n```\n\n    'Hello, World!'\n\nThe same applies to anything you import from PyPI. For instance, if you\nwanted the LLM to be able to call\n[`numpy.array`](https://numpy.org/doc/stable/reference/generated/numpy.array.html#numpy.array),\nyou would register it with `allow('numpy.array')`.\n\n`allow()` accepts two forms: strings and dicts. The simplest form is a\nbare string, which registers a single name. This works for standalone\nfunctions in the caller’s namespace:\n\n``` python\n@allow\ndef double(x): return x * 2\nawait pyrun('double(21)')\n```\n\n    42\n\nFor methods on modules or classes, use dotted string syntax. The string\nshould match how the sandbox will look up the callable, which is\n`ClassName.method` or `module.function`:\n\n``` python\nimport numpy as np\n```\n\n``` python\nallow(np.array, np.ndarray.sum)\nawait pyrun('np.array([1,2,3]).sum()')\n```\n\n    np.int64(6)\n\nNote that the string must use the actual class or module name as it\nappears in Python, not the alias. In the example above, even though the\nsandbox code uses `np`, the allowlist entry is `'numpy.array'` because\n`numpy` is the module’s real name.\n\nThe dict form is a convenient shorthand for registering multiple methods\non the same module or class at once. The key is the actual module or\nclass object, and the value is a list of method name strings:\n\n``` python\nallow({np.ndarray: ['mean', 'reshape', 'tolist']})\nawait pyrun('np.array([1,2,3,4]).reshape(2,2).mean()')\n```\n\n    np.float64(2.5)\n\nThe dict form does two things: it registers the class/module name itself\n(so it can be called as a constructor or accessed as a namespace), and\nit registers each `ClassName.method` pair. You can mix strings and dicts\nin a single `allow()` call:\n\n``` python\nallow('my_func', {np.linalg: ['norm', 'det']})\n```\n\n### The `_` suffix export convention\n\nAll symbols created in the sandbox are exported back to the caller’s\nnamespace by default — unless the name already exists and the new value\nis callable or a module (to prevent accidental shadowing). Names ending\nwith `_` (but not starting with `_`) are always exported regardless,\neven if they shadow. Note that exported callables are **not**\nautomatically available to call in subsequent sandbox runs — they must\nstill be registered with `allow()` to be callable. Non-callable exports\n(variables, data structures) are available immediately:\n\n``` python\nawait pyrun('result_ = [x**2 for x in range(5)]')\n```\n\n``` python\nresult_\n```\n\n    [0, 1, 4, 9, 16]\n\nThe exported symbols are real objects in your namespace:\n\n``` python\nawait pyrun('counts_ = {\"a\": 1, \"b\": 2}')\ncounts_\n```\n\n    {'a': 1, 'b': 2}\n\nThis is particularly useful in LLM tool loops where the model might need\nto accumulate results across steps. The `_` suffix is only needed when\nyou want to force-export a name that would otherwise be blocked (because\nit shadows an existing callable or module).\n\n### Async support\n\nThe sandbox is async-native. If the code being executed contains\n`await`, `async for`, or `async with` expressions, they work as\nexpected. Many modern Python libraries and LLM tool-calling frameworks\nare async, and you want the sandbox to be able to call into them without\nworkarounds.\n\n``` python\nawait pyrun('''\nimport asyncio\nasync def fetch(n): return n * 10\nawait asyncio.gather(fetch(1), fetch(2), fetch(3))\n''')\n```\n\n    [10, 20, 30]\n\n## Writable path permissions\n\nBy default,\n[`RunPython()`](https://AnswerDotAI.github.io/safepyrun/core.html#runpython)\nallows writes to the current working directory (`.`) and `/tmp`, and\nblocks writes elsewhere. You can pass `ok_dests` to restrict writes to a\ndifferent set of directory prefixes:\n\n``` python\npyrun2 = RunPython(ok_dests=['/tmp'])\n```\n\n``` python\nfrom pathlib import Path\n```\n\n``` python\nawait pyrun2(\"Path('/tmp/test_write.txt').write_text('hello')\")\n```\n\n    5\n\n``` python\ntry: await pyrun2(\"Path('/etc/evil.txt').write_text('bad')\")\nexcept PermissionError as e: print(f'Blocked: {e}')\n```\n\n    Blocked: Dest '/etc/evil.txt' not allowed; permitted: ('/tmp',)\n\nThe same permission checking applies to `open()` in write mode, not just\n`Path` methods:\n\n``` python\nawait pyrun2(\"open('/tmp/test_open.txt', 'w').write('hi')\")\n```\n\n    2\n\n``` python\ntry: await pyrun2(\"open('/root/bad.txt', 'w')\")\nexcept PermissionError as e: print(f'Blocked: {e}')\n```\n\n    Blocked: Dest '/root/bad.txt' not allowed; permitted: ('/tmp',)\n\nRead access is unaffected — only writes are gated:\n\n``` python\nawait pyrun2(\"open('/etc/passwd', 'r').read(10)\")\n```\n\n    '##\\n# User '\n\nHigher-level file operations like\n[`shutil.copy`](https://docs.python.org/3/library/shutil.html#shutil.copy)\nare also intercepted. The destination is checked against `ok_dests`:\n\n``` python\nawait pyrun2(\"import shutil; shutil.copy('/tmp/test_write.txt', '/tmp/test_copy.txt')\")\n```\n\n    '/tmp/test_copy.txt'\n\n``` python\ntry: await pyrun2(\"import shutil; shutil.copy('/tmp/test_write.txt', '/root/bad.txt')\")\nexcept PermissionError as e: print(f'Blocked: {e}')\n```\n\n    Blocked: Dest '/root/bad.txt' not allowed; permitted: ('/tmp',)\n\nBy default,\n[`RunPython()`](https://AnswerDotAI.github.io/safepyrun/core.html#runpython)\nuses `default_ok_dests`, which allows writes in `.` and `/tmp` but\nblocks writes elsewhere.\n\n``` python\nawait pyrun(\"Path('test_default_ok.txt').write_text('ok')\")\nawait pyrun(\"Path('/tmp/test_default_tmp.txt').write_text('tmp')\")\n\ntry: await pyrun(\"Path('/etc/nope.txt').write_text('bad')\")\nexcept PermissionError as e: print(f'Default blocked: {e}')\n```\n\n    Default blocked: Dest '/etc/nope.txt' not allowed; permitted: ('.', '/tmp')\n\nIf you want to disable write protection entirely, pass `ok_dests=None`:\n\n``` python\npyrun_unrestricted = RunPython(ok_dests=None)\nunrestricted_path = Path.home()/'safepyrun-unrestricted.txt'\nawait pyrun_unrestricted(f\"Path({str(unrestricted_path)!r}).write_text('ok')\")\n```\n\n    2\n\nYou can use `'.'` to allow writes relative to the current working\ndirectory. Path traversal attempts (`../`, `subdir/../../`) are detected\nand blocked, so the sandbox can’t escape the permitted directory:\n\n``` python\npyrun_cwd = RunPython(ok_dests=['.'])\n\n# Writing to cwd should work\nawait pyrun_cwd(\"Path('test_cwd_ok.txt').write_text('hello')\")\n```\n\n    5\n\n``` python\nPath('test_cwd_ok.txt').unlink(missing_ok=True)\n```\n\nWriting to /tmp is blocked here since it’s not in ok_dests:\n\n``` python\ntry: await pyrun_cwd(\"Path('/tmp/nope.txt').write_text('bad')\")\nexcept PermissionError: print(\"Blocked /tmp as expected\")\n```\n\n    Blocked /tmp as expected\n\nParent traversal is blocked if it resolves to a location outside\nok_dests:\n\n``` python\ntry: await pyrun_cwd(\"Path('../escape.txt').write_text('bad')\")\nexcept PermissionError: print(\"Blocked ../ as expected\")\n```\n\n    Blocked ../ as expected\n\n### Write policies\n\nWhen `ok_dests` is set, safepyrun uses write policies to determine how\nto validate each callable’s destination arguments. Three built-in policy\nclasses cover common patterns: checking a positional or keyword argument\n(`PosAllowPolicy`), checking the `Path` object itself\n(`PathWritePolicy`), and checking `open()` calls only when the mode is\nwritable (`OpenWritePolicy`). You can also subclass `AllowPolicy` to\ncreate custom checks.\n\nThe simplest, `PosAllowPolicy`, checks a specific positional or keyword\nargument against the allowed destinations. Here, position 1 (or keyword\n`dst`) is validated — writing to `/tmp` is allowed, but `/root` is\nblocked:\n\n``` python\npp = PosAllowPolicy(1, 'dst')\npp(None, ['src', '/tmp/ok'], {}, ['/tmp'])\ntry: pp(None, ['src', '/root/bad'], {}, ['/tmp'])\nexcept PermissionError: print(\"PosAllowPolicy correctly blocked /root/bad\")\n```\n\n    PosAllowPolicy correctly blocked /root/bad\n\nYou can create custom write policies by subclassing `AllowPolicy` and\nimplementing `__call__`. For example, here we show a policy that only\nallows writes to files with specific extensions — useful if you want the\nLLM to create `.csv` or `.json` files but not arbitrary scripts.\n\nThe `__call__` signature receives `(obj, args, kwargs, ok_dests)` where\n`obj` is the object the method is called on (e.g. a `Path` instance),\n`args`/`kwargs` are the method’s arguments, and `ok_dests` is the list\nof permitted directory prefixes. Calling `chk_dest` first handles the\ndirectory check, then the custom logic adds the extension constraint on\ntop.\n\n``` python\nclass ExtWritePolicy(AllowPolicy):\n    \"Only allow writes to paths with specified extensions\"\n    def __init__(self, exts): self.exts = set(exts)\n    def __call__(self, obj, args, kwargs, ok_dests):\n        chk_dest(obj, ok_dests)\n        if Path(str(obj)).suffix not in self.exts: raise PermissionError(f\"{Path(str(obj)).suffix!r} not allowed\")\n```\n\n``` python\nep = ExtWritePolicy(['.csv', '.json'])\nep(Path('/tmp/data.csv'), [], {}, ['/tmp'])\ntry: ep(Path('/tmp/script.sh'), [], {}, ['/tmp'])\nexcept PermissionError: print(\"ExtWritePolicy correctly blocked .sh\")\n```\n\n    ExtWritePolicy correctly blocked .sh\n\nYou can register it with `allow` just like the built-in policies:\n\n``` python\nallow({Path: [('write_text', ExtWritePolicy(['.csv', '.json', '.txt']))]})\n```\n\n## Configuration\n\n`safepyrun` loads an optional user config from\n`{xdg_config_home}/safepyrun/config.py` at import time, after all\ndefaults are registered. This lets you permanently extend the sandbox\nallowlists without modifying the package. The config file is executed\nwith all `safepyrun.core` globals already available, so no imports are\nneeded. This includes `allow`,\n[`allow_write_types`](https://AnswerDotAI.github.io/safepyrun/core.html#allow_write_types),\n`AllowPolicy`, `PathWritePolicy`, `PosAllowPolicy`, `OpenWritePolicy`,\nand all standard library modules already imported by the module.\n\nExample `~/.config/safepyrun/config.py` (Linux) or\n`~/Library/Application Support/safepyrun/config.py` (macOS):\n\n``` python\nimport pandas\n\n# Add pandas tools\nallow({pandas.DataFrame: ['head', 'describe', 'info', 'shape']})\n\n# Allow pandas to write CSV to ~/data\nallow({pandas.DataFrame: [('to_csv', PosAllowPolicy(0, 'path_or_buf'))]})\n```\n\nIf the config file has errors, a warning is emitted and the defaults\nremain intact.\n\n## CLI\n\nsafepyrun ships with a command-line tool that runs a Python script file\nin the sandbox. You can pass a file path, or pipe code in via stdin:\n\n``` sh\n# Run a script file\n$ safepyrun myscript.py\n\n# Pipe code via stdin\n$ echo \"1+1\" | safepyrun\n```\n\nThe result of the last expression is printed to stdout, matching the\nbehaviour of `pyrun` in Python. Errors are reported to stderr.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fanswerdotai%2Fsafepyrun","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fanswerdotai%2Fsafepyrun","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fanswerdotai%2Fsafepyrun/lists"}