Metadata-Version: 2.4
Name: safepyrun
Version: 0.2.8
Summary: Safe(ish) running of python code
Author-email: Jeremy Howard <github@jhoward.fastmail.fm>
License: Apache-2.0
Project-URL: Repository, https://github.com/AnswerDotAI/safepyrun
Project-URL: Documentation, https://AnswerDotAI.github.io/safepyrun/
Keywords: nbdev
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: fastaudit>=0.2.9
Requires-Dist: pyskills>=0.0.17
Requires-Dist: fastcore>=1.14.6
Requires-Dist: httpx
Requires-Dist: matplotlib>=3.10.8
Provides-Extra: dev
Requires-Dist: numpy; extra == "dev"
Requires-Dist: pandas; extra == "dev"
Requires-Dist: matplotlib; extra == "dev"
Requires-Dist: ipykernel_helper>=0.0.58; extra == "dev"
Dynamic: license-file

# safepyrun


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

*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](https://github.com/AnswerDotAI/safecmd), which checks bash commands.

## Installation

Install from [pypi](https://pypi.org/project/safepyrun/)

``` sh
$ pip install safepyrun
```

## Background

An 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`](https://docs.python.org/3/library/shutil.html#shutil.rmtree), [`os.remove`](https://docs.python.org/3/library/os.html#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](https://github.com/AnswerDotAI/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`](https://docs.python.org/3/library/sys.monitoring.html#module-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](https://github.com/AnswerDotAI/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.

## Usage

``` python
from safepyrun import *
from pyskills import *
import subprocess, httpx
```

Create 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
python = RunPython()
```

``` python
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`:

``` python
await python('print("hello"); 1+1')
```

    hello

    2

Modules can be imported. stderr is also captured:

``` python
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:

``` python
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`.

### The `allow()` function

Sandboxed code can call functions in your namespace. Pure functions need no registration. A function that runs a subprocess needs permission:

``` python
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

``` python
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:

``` python
@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:

``` python
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:

``` python
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:

``` python
allow(echo, {Shell: ['date', 'whoami']})
```

Callable instances are registered individually. This lets dynamically generated client operations, such as [fastspec](https://github.com/AnswerDotAI/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()`](https://AnswerDotAI.github.io/safepyrun/core.html#allow_matplotlib) and [`allow_pandas()`](https://AnswerDotAI.github.io/safepyrun/core.html#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`.

### The `_` suffix export convention

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 `_`:

``` python
await python('result_ = [x**2 for x in range(5)]')
```

``` python
result_
```

    [0, 1, 4, 9, 16]

The exported symbols are real objects in your namespace:

``` python
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.

### Async support

Sandboxed code supports `await`, `async for`, and `async with`. It can call async libraries and LLM tool-calling frameworks directly:

``` python
import asyncio
```

``` python
async def fetch(n): return n * 10
```

``` python
await python('''
await asyncio.gather(fetch(1), fetch(2), fetch(3))
''')
```

    [10, 20, 30]

## Writable path permissions

By default, [`RunPython()`](https://AnswerDotAI.github.io/safepyrun/core.html#runpython) permits writes in the current working directory (`.`) and `/tmp`. Writes elsewhere are blocked. Set `ok_dests` to choose different directory prefixes:

``` python
python2 = RunPython(ok_dests=['/tmp'])
```

``` python
from pathlib import Path
```

``` python
await python2("Path('/tmp/test_write.txt').write_text('hello')")
```

    5

``` python
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:

``` python
await python2("open('/tmp/test_open.txt', 'w').write('hi')")
```

    2

``` python
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:

``` python
await python2("open('/etc/passwd', 'r').read(10)")
```

    '##\n# User '

Higher-level file operations like [`shutil.copy`](https://docs.python.org/3/library/shutil.html#shutil.copy) are also intercepted. The destination is checked against `ok_dests`:

``` python
await python2("import shutil; shutil.copy('/tmp/test_write.txt', '/tmp/test_copy.txt')")
```

    '/tmp/test_copy.txt'

``` python
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()`](https://AnswerDotAI.github.io/safepyrun/core.html#runpython) uses `default_ok_dests`, which allows writes in `.` and `/tmp` but blocks writes elsewhere.

``` python
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
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
python_cwd = RunPython(ok_dests=['.'])

# Writing to cwd should work
await python_cwd("Path('test_cwd_ok.txt').write_text('hello')")
```

    5

``` python
Path('test_cwd_ok.txt').unlink(missing_ok=True)
```

Writing to /tmp is blocked here since it’s not in ok_dests:

``` python
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:

``` python
try: await python_cwd("Path('../escape.txt').write_text('bad')")
except PermissionError: print("Blocked ../ as expected")
```

    Blocked ../ as expected

### Write policies

Write policies validate a callable’s destination arguments against `ok_dests`. The built-in policies check different forms of destination:

- `PosAllowPolicy` checks a positional or keyword argument.
- `PathWritePolicy` checks the `Path` object itself.
- `OpenWritePolicy` checks `open()` 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:

``` python
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 a `Path`.
- `args` and `kwargs`: the method’s arguments.
- `ok_dests`: the permitted directory prefixes.

Call `chk_dest` to check the directory before applying the extension restriction:

``` python
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")
```

``` python
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:

``` python
allow({Path: [('write_text', ExtWritePolicy(['.csv', '.json', '.txt']))]})
```

## Configuration

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:

``` python
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.

## CLI

The `safepyrun` command runs Python code in the sandbox. Pass a script path or pipe code through stdin:

``` sh
# Run a script file
$ safepyrun myscript.py

# Pipe code via stdin
$ echo "1+1" | safepyrun
```

The command prints the last expression’s result to stdout, matching the Python interface. Errors go to stderr.
