safepyrun is an allowlist-based Python sandbox that lets LLMs execute code safely(ish) in your real environment. It runs in your existing Python process, with access to your libraries, data, and tools. You do not need to recreate that environment in a container.
An audit layer checks side effects. Filesystem writes outside approved directories, subprocesses, and network access are blocked unless they occur inside an explicitly trusted callable.
It is the Python counterpart to safecmd, which checks bash commands.
Install from pypi
$ pip install safepyrunAn LLM running code on your behalf often needs your files, libraries, running processes, and data. A container isolates code from that environment. Giving it access requires volume mounts and installed dependencies that reproduce the parts of your environment the task needs.
Running the code directly with exec gives it access to your existing Python objects. It also gives it access to operations such as shutil.rmtree, os.remove, and subprocess.run("rm -rf /").
safepyrun runs the code in your Python process and checks its side effects. Ordinary computation includes string handling, math, JSON parsing, path inspection, and data structures. Writes are limited to approved directories. Subprocesses, sockets, and other checked operations are denied unless they occur inside a function registered with allow().
safepyrun uses fastaudit to check Python audit events. CPython raises these events for operations such as writing files, spawning subprocesses, connecting sockets, and deleting paths. fastaudit denies the events unless a policy callback approves them.
On Python 3.12 and newer, fastaudit also uses sys.monitoring to check calls into native extension modules that audit hooks would otherwise miss. Audited libraries such as numpy declare safe calls through the fastaudit_safe_native entry point. Those calls are not monitored.
safepyrun supplies the policy callback. It checks the call stack and fastaudit’s record of active async calls for a callable registered with allow(). An approved call permits the operation. Otherwise the code gets a PermissionError.
An AST check also applies to submitted code. It disallows imports such as socket and importlib, def and class statements, and calls to exec, eval, or compile.
In-process sandboxing does not protect against a determined adversary. safepyrun is intended for an LLM acting as a well-meaning but occasionally clumsy collaborator. A hallucinated cleanup step or a misunderstood request can cause damage without any deliberate escape attempt. This is the same “safe-ish” approach used in safecmd for bash.
The LLM can read files, parse data, and call into your libraries. Approved directories and trusted callables define which side effects are allowed.
from safepyrun import *
from pyskills import *
import subprocess, httpxCreate the sandbox with python = RunPython(). Call it with a string of Python code and await the result. It returns the last expression and captures printed output separately. Errors are caught and reported to the caller.
python = RunPython()await python('1+1')2
Code can print output and return a value in the same call. Printed output appears in stdout. The last expression becomes result:
await python('print("hello"); 1+1')hello
2
Modules can be imported. stderr is also captured:
await python('''
import warnings
warnings.warn('a warning')
"ok"
''')<python_3>:2: UserWarning: a warning
warnings.warn('a warning')
'ok'
Most standard-library computation needs no special permissions. For example, you can import re and match a pattern:
await python('import re; re.findall(r"\\d+", "there are 3 cats and 10 dogs")')['3', '10']
Text processing, math, data structures, iteration, functional tools, dates, encoding, serialization, and introspection are available. Read-only filesystem access is also allowed.
The AST pre-check still applies. Submitted code cannot import socket, importlib, or safepyrun itself. It cannot define functions or classes, or call exec, eval, or compile.
Sandboxed code can call functions in your namespace. Pure functions need no registration. A function that runs a subprocess needs permission:
def echo(msg): return subprocess.run(['echo', msg], capture_output=True, text=True).stdout
try: await python('echo("hi")')
except PermissionError as e: print(f'Blocked: {str(e)[:80]}')Blocked: Audit: subprocess.Popen blocked in sandbox with args: ('echo', ['echo', 'hi'], N
allow(echo)
await python('echo("hi")')'hi\n'
Registering a callable marks it as trusted. Its implementation can perform otherwise-denied operations while it runs.
You can also register a function with the @allow decorator. This example gives the LLM a function that makes a network request:
@allow
def getexample(): return httpx.get('http://example.org')
await python('getexample()')<Response [200 OK]>
Pass a method to allow to register it under its class. The method is then allowed on every instance of that class:
class Shell:
def date(self): return subprocess.run(['date'], capture_output=True, text=True).stdout
def whoami(self): return subprocess.run(['whoami'], capture_output=True, text=True).stdout
sh = Shell()
allow(Shell.date)
await python('sh.date()')'Wed Jul 8 07:22:57 AEST 2026\n'
The dict form registers several methods on one class or module at once. The key is the actual module or class object, and the value is a list of method names:
allow({Shell: ['date', 'whoami']})
await python('sh.whoami()')'jhoward\n'
Use (name, policy) tuples as dictionary values to validate individual calls. The write-policy examples below demonstrate this form. One allow call can register several items:
allow(echo, {Shell: ['date', 'whoami']})Callable instances are registered individually. This lets dynamically generated client operations, such as fastspec’s, have separate permissions even when they share a class and __call__ method.
To trust a package, use a string prefix key: allow({'mypkg.*': ...}). This permits otherwise-denied operations inside functions defined in mypkg or its submodules. The ... value grants full trust.
Named registrations and (name, policy) tuples on the same call stack are checked before package-wide trust. A policy that raises PermissionError denies the operation even when the package prefix matches. You can use this to restrict specific calls within a broadly trusted package.
Native extension calls need registration with allow() unless fastaudit already declares them safe. Audited libraries including numpy, pandas’ core, matplotlib, regex, and orjson use the fastaudit_safe_native entry point for this purpose.
allow_matplotlib() and allow_pandas() register save methods with write policies. Their computation is already permitted by the entry point. The helpers check save destinations against ok_dests.
Symbols created in the sandbox are exported to the caller’s namespace by default. An existing name is not replaced when the new value is callable or a module.
Names ending with _ force export even when they shadow an existing name. This rule excludes names that also start with _:
await python('result_ = [x**2 for x in range(5)]')result_[0, 1, 4, 9, 16]
The exported symbols are real objects in your namespace:
await python('counts_ = {"a": 1, "b": 2}')
counts_{'a': 1, 'b': 2}
Exported variables let an LLM tool loop accumulate results across calls. The _ suffix is needed only to override the shadowing restriction for an existing callable or module.
Sandboxed code supports await, async for, and async with. It can call async libraries and LLM tool-calling frameworks directly:
import asyncioasync def fetch(n): return n * 10await python('''
await asyncio.gather(fetch(1), fetch(2), fetch(3))
''')[10, 20, 30]
By default, RunPython() permits writes in the current working directory (.) and /tmp. Writes elsewhere are blocked. Set ok_dests to choose different directory prefixes:
python2 = RunPython(ok_dests=['/tmp'])from pathlib import Pathawait python2("Path('/tmp/test_write.txt').write_text('hello')")5
try: await python2("Path('/etc/evil.txt').write_text('bad')")
except PermissionError as e: print(f'Blocked: {e}')Blocked: open '/etc/evil.txt' not in ('/private/tmp',)
The same permission checking applies to open() in write mode, not just Path methods:
await python2("open('/tmp/test_open.txt', 'w').write('hi')")2
try: await python2("open('/root/bad.txt', 'w')")
except PermissionError as e: print(f'Blocked: {e}')Blocked: open '/root/bad.txt' not in ('/private/tmp',)
Read access is unaffected by the write restrictions:
await python2("open('/etc/passwd', 'r').read(10)")'##\n# User '
Higher-level file operations like shutil.copy are also intercepted. The destination is checked against ok_dests:
await python2("import shutil; shutil.copy('/tmp/test_write.txt', '/tmp/test_copy.txt')")'/tmp/test_copy.txt'
try: await python2("import shutil; shutil.copy('/tmp/test_write.txt', '/root/bad.txt')")
except PermissionError as e: print(f'Blocked: {e}')Blocked: shutil.copyfile '/root/bad.txt' not in ('/private/tmp',)
By default, RunPython() uses default_ok_dests, which allows writes in . and /tmp but blocks writes elsewhere.
await python("Path('test_default_ok.txt').write_text('ok')")
await python("Path('/tmp/test_default_tmp.txt').write_text('tmp')")
try: await python("Path('/etc/nope.txt').write_text('bad')")
except PermissionError as e: print(f'Default blocked: {e}')
Path('test_default_ok.txt').unlink()Default blocked: open '/etc/nope.txt' not in ('.', '/private/tmp', '/Users/jhoward/aai-ws', '/Users/jhoward/git')
If you want to disable write protection entirely, pass ok_dests=None:
python_un = RunPython(ok_dests=None)
un_path = Path.home()/'safepyrun-un.txt'
await python_un(f"Path({str(un_path)!r}).write_text('ok')")Use '.' to permit writes relative to the current working directory. Paths containing ../ or subdir/../../ are checked after resolution. Writes outside the permitted directory are blocked:
python_cwd = RunPython(ok_dests=['.'])
# Writing to cwd should work
await python_cwd("Path('test_cwd_ok.txt').write_text('hello')")5
Path('test_cwd_ok.txt').unlink(missing_ok=True)Writing to /tmp is blocked here since it’s not in ok_dests:
try: await python_cwd("Path('/tmp/nope.txt').write_text('bad')")
except PermissionError: print("Blocked /tmp as expected")Blocked /tmp as expected
Parent traversal is blocked if it resolves to a location outside ok_dests:
try: await python_cwd("Path('../escape.txt').write_text('bad')")
except PermissionError: print("Blocked ../ as expected")Blocked ../ as expected
Write policies validate a callable’s destination arguments against ok_dests. The built-in policies check different forms of destination:
PosAllowPolicychecks a positional or keyword argument.PathWritePolicychecks thePathobject itself.OpenWritePolicychecksopen()when the mode permits writes.
You can subclass AllowPolicy for custom checks.
Here, PosAllowPolicy checks position 1 or keyword dst. The permitted destination is /tmp. A path under /root is denied:
pp = PosAllowPolicy(1, 'dst')
pp(None, ['src', '/tmp/ok'], {}, dict(ok_dests=['/tmp']))
try: pp(None, ['src', '/root/bad'], {}, dict(ok_dests=['/tmp']))
except PermissionError: print("PosAllowPolicy correctly blocked /root/bad")PosAllowPolicy correctly blocked /root/bad
Create a custom write policy by subclassing AllowPolicy and implementing __call__. This example permits files with selected extensions, such as .csv and .json, while excluding scripts.
The arguments to __call__ are:
obj: the object on which the method was called, such as aPath.argsandkwargs: the method’s arguments.ok_dests: the permitted directory prefixes.
Call chk_dest to check the directory before applying the extension restriction:
class ExtWritePolicy(AllowPolicy):
"Only allow writes to paths with specified extensions"
def __init__(self, exts): self.exts = set(exts)
def __call__(self, obj, args, kwargs, ok_dests):
chk_dest(obj, ok_dests)
if Path(str(obj)).suffix not in self.exts: raise PermissionError(f"{Path(str(obj)).suffix!r} not allowed")ep = ExtWritePolicy(['.csv', '.json'])
ep(Path('/tmp/data.csv'), [], {}, ['/tmp'])
try: ep(Path('/tmp/script.sh'), [], {}, ['/tmp'])
except PermissionError: print("ExtWritePolicy correctly blocked .sh")ExtWritePolicy correctly blocked .sh
You can register it with allow just like the built-in policies:
allow({Path: [('write_text', ExtWritePolicy(['.csv', '.json', '.txt']))]})safepyrun loads optional configuration from {xdg_config_home}/safepyrun/config.py at import time, after registering its defaults. Use this file to extend allowlists without modifying the package.
The file runs with safepyrun.core globals available. These include allow, allow_write_types, AllowPolicy, PathWritePolicy, PosAllowPolicy, and OpenWritePolicy. Standard-library modules already imported by safepyrun are also available.
On Linux, use ~/.config/safepyrun/config.py. On macOS, use ~/Library/Application Support/safepyrun/config.py. For example:
import pandas
# Add pandas tools
allow({pandas.DataFrame: ['head', 'describe', 'info', 'shape']})
# Allow pandas to write CSV to ~/data
allow({pandas.DataFrame: [('to_csv', PosAllowPolicy(0, 'path_or_buf'))]})Errors in the config file produce a warning. The defaults remain intact.
The safepyrun command runs Python code in the sandbox. Pass a script path or pipe code through stdin:
# Run a script file
$ safepyrun myscript.py
# Pipe code via stdin
$ echo "1+1" | safepyrunThe command prints the last expression’s result to stdout, matching the Python interface. Errors go to stderr.