├── .gitattributes ├── .github └── workflows │ └── python-app.yml ├── .gitignore ├── LICENSE ├── README.md ├── example_sandbox.py ├── requirements ├── pip-compile.sh ├── pip-install.sh ├── requirements-dev.in ├── requirements-dev.txt ├── requirements.in └── requirements.txt └── wapm.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | name: Setup and Test 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ${{ matrix.os }} 16 | 17 | strategy: 18 | matrix: 19 | python: 20 | - "3.7" 21 | - "3.8" 22 | - "3.9" 23 | - "3.10" 24 | #- "3.11" <- failing 25 | os: 26 | - ubuntu-latest 27 | - windows-latest 28 | - macos-latest 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | - name: Setup Wasmer 33 | uses: wasmerio/setup-wasmer@v3 34 | - name: install python wasm 35 | # pipe "y" into wapm install to confirm key ssh key 36 | run: | 37 | echo y | wapm install python 38 | - name: Set up Python ${{ matrix.python }} 39 | uses: actions/setup-python@v4 40 | with: 41 | python-version: "${{ matrix.python }}" 42 | - name: Install Python Dependencies 43 | run: | 44 | python -m pip install --upgrade pip 45 | pip install -r requirements/requirements.txt 46 | - name: Run Example Sandbox 47 | run: | 48 | python example_sandbox.py 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | .idea/ 153 | 154 | # wapm packages 155 | wapm_packages/ 156 | 157 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jim Kring 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Sandbox in Web Assembly (wasm) 2 | 3 | This is proof-of-concept for executing python code in a sandboxed web assembly (wasm) build of python. 4 | It uses wasmer-python to run the wasm build of python and wapm to install the python package. 5 | 6 | [![Setup and Test](https://github.com/jimkring/python-sandbox-wasm/actions/workflows/python-app.yml/badge.svg)](https://github.com/jimkring/python-sandbox-wasm/actions/workflows/python-app.yml) 7 | 8 | Compatibility: 9 | 10 | - OS: Linux, MacOS, and Windows (tested on ubuntu-latest, macos-latest and windows-latest github runners) 11 | - Python: 3.7, 3.8, 3.9 and 3.10 (tested with standard cpython [actions/setup-python](https://github.com/actions/setup-python)) 12 | 13 | Incompatibility: 14 | 15 | - NOT WORKING WITH python 3.11 (seems wasm-python library does not support 3.11 yet, as of Jan 14, 2023) 16 | 17 | ## How it All Works 18 | 19 | - From our host python we use the [wasmer-python](https://github.com/wasmerio/wasmer-python) library to load and run the [python web assembly](https://wapm.io/python/python) (wasm) from wapm (web assembly package manager). 20 | 21 | - we create a "sandbox" folder that is shared with the web assembly python instance. 22 | 23 | - we take the unsafe python code (to be executed in the sandbox) and write it to a python file in the sandbox folder. We prepend to that file a few lines of code that will write the standard output (stdout) to a file in the sandbox folder (which we will then read back into the host python instance). 24 | 25 | - we run the python web assembly instance with the sandboxed python file we just created as the argument, so that the unsafe code runs inside the web assembly instance. 26 | 27 | - we then read the standard output file from the sandbox folder and return it to the host python instance. 28 | 29 | > Note: it does not seem possible to redirect the std output of the web assembly to file FROM THE HOST PYTHON. This only works from the python code running inside the web assembly. That's why we have to prepend the unsafe code with a few lines of code that will write the std output to a file. 30 | 31 | ## Useful Resources 32 | Tools and examples used here: 33 | 34 | - [starting point example](https://github.com/wasmerio/wasmer-python/blob/master/examples/wasi.py) - this was the starting point for this project/code ([example_sandbox.py](https://github.com/jimkring/python-sandbox-wasm/blob/main/example_sandbox.py)) 35 | - [wasmer-python](https://github.com/wasmerio/wasmer-python) - the python library that we use to load/compile/run the python.wasm and our sandboxed python script 36 | - [wapm python.wasm package](https://wapm.io/python/python) - the python web assembly package that installs python.wasm into our project folder so we can call it with wasmer. 37 | 38 | ## Required Tools and Setup 39 | 40 | ### Install wasmer and wapm 41 | 42 | Again, wasmer is the web assembly runtime and wapm is the web assembly package manager that will install the python web assembly package. 43 | 44 | On MacOS with homebrew 45 | ```shell 46 | $ brew install wasmer 47 | $ brew install wapm 48 | ``` 49 | 50 | From the instructions on [wasmer.io](https://wasmer.io/) 51 | ```shell 52 | $ curl https://get.wasmer.io -sSfL | sh 53 | ``` 54 | 55 | ### Install python web assembly using wapm 56 | 57 | ```shell 58 | $ wapm install python/python 59 | ``` 60 | 61 | This will result in a wapm_packages directory in your project folder, which contains python compiled as a web assembly. 62 | 63 | ### Setup your python environment 64 | 65 | We're not going to setup our "host" python environment that will run our safe/trusted code. 66 | 67 | create a virtual environment as you would normally do (this is just and example using `venv`) 68 | ```shell 69 | python -m venv .venv 70 | ``` 71 | 72 | activate the virtual environment (if you are using one) 73 | ```shell 74 | $ source .venv/bin/activate 75 | ``` 76 | 77 | install packages in the [requirements.txt](https://github.com/jimkring/python-sandbox-wasm/blob/main/requirements/requirements.txt) file 78 | ```shell 79 | $ pip install -r requirements/requirements.txt 80 | ``` 81 | 82 | ## Run the example 83 | 84 | Run the example_sandbox.py file 85 | ```python 86 | $ python example_sandbox.py 87 | wasi stdout 88 | WARNING: this string is the result of executing unknown code, so be careful how you use it! 89 | #### 90 | Hello, world! 91 | #### 92 | ``` 93 | 94 | > Note: You can see the code run in [this GitHub action](https://github.com/jimkring/python-sandbox-wasm/actions/workflows/python-app.yml) workflow that tests it. You can verify it's working because you can see the example output: 95 | 96 | image 97 | 98 | # Future Needs (Roadmap?) 99 | 100 | ## Calling into Python C API 101 | 102 | It would be great to be able call into the python web assembly using functions/exports from the [python c api](https://docs.python.org/3/c-api/) that expose the interpreter. Right now, we are invoking the `_start()` which is like running python from a command line -- we can pass arguments and set environment variables as we run it. 103 | 104 | This would require a new build of the python.wasm possibly building it as a dynamic library. The nodjs package [python-wasm](https://www.npmjs.com/package/python-wasm) for embedding python takes an approach like this, I believe. 105 | 106 | ## Configurable Resource Limits (RAM, Disk, CPU, etc) 107 | 108 | It would be good to limit the resources available to the sandboxed code. Wasmer (and the wasmer python api) exposes some options for this, I believe. 109 | -------------------------------------------------------------------------------- /example_sandbox.py: -------------------------------------------------------------------------------- 1 | """ 2 | Execute python code in a sandboxed python web assembly. 3 | """ 4 | import os 5 | from pathlib import Path 6 | 7 | from wasmer.wasmer import ImportObject, Instance, Module, Store, engine, wasi 8 | from wasmer_compiler_cranelift.wasmer_compiler_cranelift import Compiler 9 | 10 | # Load the wasm as bytes 11 | __dir__ = os.path.dirname(os.path.realpath(__file__)) 12 | wasm_bytes = open("wapm_packages/python/python@0.1.0/bin/python.wasm", "rb").read() 13 | 14 | # Create a store. 15 | store = Store(engine.Universal(Compiler)) 16 | 17 | # compile the wasm module 18 | module = Module(store, wasm_bytes) 19 | 20 | # Get the wasi version -- we'll need it below 21 | wasi_version = wasi.get_version(module, strict=True) 22 | 23 | # Create a `wasi.Environment` with the the `wasi.StateBuilder`. 24 | # 25 | # In this case, we specify the program name is `wasi_test_program`. We 26 | # also specify the program is invoked with the `--test` argument, in 27 | # addition to two environment variable: `COLOR` and 28 | # `APP_SHOULD_LOG`. Finally, we map the `the_host_current_dir` to the 29 | # current directory. There it is: 30 | 31 | unsafe_python_code = """ 32 | print('Hello, world!') 33 | """ 34 | 35 | stdout_file = "out.txt" 36 | 37 | # wrap python code, redirecting stdout to a file 38 | wrapped_python_code = f""" 39 | 40 | # redirect stdout to a file 41 | import sys 42 | sys.stdout = open("sandbox/{stdout_file}", "w") 43 | 44 | # unsafe python code starts here 45 | {unsafe_python_code} 46 | 47 | """ 48 | 49 | 50 | # Define sandbox directory. This and the "lib" dir are the only dirs 51 | # the wasm can access. 52 | sandbox_dir = Path.cwd() / "sandbox" 53 | 54 | # create sandbox dir if it doesn't exist 55 | sandbox_dir.mkdir(exist_ok=True) 56 | 57 | # Define the name of the python file to host the executable code 58 | sandbox_py = "sandbox.py" 59 | 60 | sandbox_py_path = sandbox_dir / sandbox_py 61 | 62 | # delete sandbox py file if it exists 63 | if sandbox_py_path.exists(): 64 | sandbox_py_path.unlink() 65 | 66 | sandbox_stdout_path = sandbox_dir / stdout_file 67 | 68 | # delete sandbox stdout file if it exists 69 | if sandbox_stdout_path.exists(): 70 | sandbox_stdout_path.unlink() 71 | 72 | # write the code to the sandbox 73 | with open(sandbox_dir / sandbox_py, "w") as f: 74 | f.write(wrapped_python_code) 75 | 76 | py_program_name = "python" 77 | py_lib_dir = "wapm_packages/python/python@0.1.0/lib" 78 | py_environment = { 79 | # 'VARIABLE': 'value', 80 | } 81 | py_arguments = [ 82 | f"sandbox/{sandbox_py}", 83 | ] 84 | 85 | # let's build the environment in a wasi state object 86 | wasi_state = wasi.StateBuilder(py_program_name) 87 | 88 | # set the command arguments 89 | for arg in py_arguments: 90 | wasi_state.argument(arg) 91 | 92 | # set the environment variables 93 | for key, value in py_environment.items(): 94 | wasi_state.environment(key, value) 95 | 96 | # map the python lib directory 97 | wasi_state.map_directory("lib", py_lib_dir) 98 | wasi_state.map_directory("sandbox", str(sandbox_dir)) 99 | 100 | # # Create the in-memory "file" 101 | # wasi_stdout = StringIO() 102 | 103 | # wasi_state.set_stdout(wasi_stdout) 104 | 105 | # finalize the environment 106 | wasi_env = wasi_state.finalize() 107 | 108 | # From the WASI environment, generate a custom, pre-configured import object. 109 | # 110 | # Note we need the WASI version here. 111 | import_object = wasi_env.generate_import_object(store, wasi_version) 112 | 113 | # Instantiate the module. 114 | instance = Instance(module, import_object) 115 | 116 | # # Redirect stdout to a file before running the WASI module (NOTE: this is NOT working!) 117 | # with open("out.txt", "w") as f: 118 | # with redirect_stdout(f): 119 | # print(f"before wasi") 120 | 121 | # # The entry point for a WASI WebAssembly module is a function named `_start` 122 | # instance.exports._start() 123 | 124 | # The entry point for a WASI WebAssembly module is a function named `_start` 125 | instance.exports._start() 126 | 127 | # read the sandbox's stdout into a string 128 | # NOTE: this string is the result of executing unknown code, so be careful how you use it!) 129 | with open(sandbox_stdout_path, "r") as f: 130 | wasi_stdout_unsafe = f.read() 131 | 132 | print( 133 | f"wasi stdout\nWARNING: this string is the result of executing unknown code, " 134 | f"so be careful how you use it!\n####\n{wasi_stdout_unsafe}####" 135 | ) 136 | 137 | # todo: move this to top of file 138 | LEAVE_SANDBOX_FILES_FOR_DEBUG = False 139 | 140 | # clean up sandbox files if we're not debugging 141 | if not LEAVE_SANDBOX_FILES_FOR_DEBUG: 142 | sandbox_py_path.unlink() 143 | sandbox_stdout_path.unlink() 144 | 145 | # this assertion is helpful for CI testing so we know if the sandbox isn't working 146 | assert "Hello, world!" in wasi_stdout_unsafe 147 | -------------------------------------------------------------------------------- /requirements/pip-compile.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | echo "compiling requirements" 3 | pip-compile --quiet --resolver=backtracking --output-file requirements.txt requirements.in 4 | echo "compiling requirements-dev" 5 | pip-compile --quiet --resolver=backtracking --output-file requirements-dev.txt requirements-dev.in 6 | echo "requirements compiled" 7 | -------------------------------------------------------------------------------- /requirements/pip-install.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | pip install -r requirements.txt -r requirements-dev.txt -------------------------------------------------------------------------------- /requirements/requirements-dev.in: -------------------------------------------------------------------------------- 1 | -c requirements.txt 2 | pip-tools 3 | -------------------------------------------------------------------------------- /requirements/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.10 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements-dev.txt --resolver=backtracking requirements-dev.in 6 | # 7 | build==0.10.0 8 | # via pip-tools 9 | click==8.1.3 10 | # via pip-tools 11 | packaging==23.0 12 | # via build 13 | pip-tools==6.12.1 14 | # via -r requirements-dev.in 15 | pyproject-hooks==1.0.0 16 | # via build 17 | req==1.0.0 18 | # via -r requirements-dev.in 19 | six==1.16.0 20 | # via req 21 | tomli==2.0.1 22 | # via 23 | # build 24 | # pyproject-hooks 25 | wheel==0.38.4 26 | # via pip-tools 27 | 28 | # The following packages are considered to be unsafe in a requirements file: 29 | # pip 30 | # setuptools 31 | -------------------------------------------------------------------------------- /requirements/requirements.in: -------------------------------------------------------------------------------- 1 | wasmer 2 | wasmer_compiler_cranelift -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.10 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements.txt --resolver=backtracking requirements.in 6 | # 7 | wasmer==1.1.0 8 | # via -r requirements.in 9 | wasmer-compiler-cranelift==1.1.0 10 | # via -r requirements.in 11 | -------------------------------------------------------------------------------- /wapm.lock: -------------------------------------------------------------------------------- 1 | # Lockfile v4 2 | # This file is automatically generated by Wapm. 3 | # It is not intended for manual editing. The schema of this file may change. 4 | [modules."python/python"."0.1.0".python] 5 | name = "python" 6 | package_version = "0.1.0" 7 | package_name = "python/python" 8 | package_path = "python/python@0.1.0" 9 | resolved = "https://registry-cdn.wapm.io/packages/_/python/python-0.1.0.tar.gz" 10 | resolved_source = "registry+python" 11 | abi = "wasi" 12 | source = "bin/python.wasm" 13 | [commands.python] 14 | name = "python" 15 | package_name = "python/python" 16 | package_version = "0.1.0" 17 | module = "python" 18 | is_top_level_dependency = true 19 | --------------------------------------------------------------------------------